JSON in Edge Functions: Vercel, Cloudflare Workers & Deno Deploy
Last updated:
Edge functions run JSON request/response handling using the standard Web Fetch API — Response.json(data) constructs a JSON response with Content-Type: application/json automatically, and await request.json() parses the request body, both available in V8 Isolate environments at Vercel, Cloudflare Workers, and Deno Deploy. Edge functions cold-start in under 5 ms (vs 100-500 ms for serverless functions) because they use V8 Isolates instead of full Node.js runtimes — the tradeoff is no native Node.js APIs: no fs, no Buffer, and limited crypto, requiring all JSON processing to use Web APIs. This guide covers Web Fetch API JSON patterns, Vercel Edge Functions and Middleware JSON handling, Cloudflare Workers KV for JSON storage, Deno Deploy JSON responses, edge caching of JSON with Cache-Control, and runtime limitations affecting JSON processing.
Web Fetch API JSON Patterns: Response.json and request.json
Response.json() and request.json() are the primary JSON primitives in all edge runtimes — both are part of the standard Web Fetch API and available without any imports. Response.json(data) is a static constructor that serializes a JavaScript value and sets Content-Type: application/json automatically. request.json() reads and parses the request body stream; it throws a SyntaxError if the body is not valid JSON. Always wrap it in try/catch and return a 400 response on failure.
// Response.json() — static constructor (Web Fetch API)
// Replaces: new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } })
return Response.json({ message: 'ok' })
// With status and custom headers
return Response.json(
{ error: 'Not found', code: 'NOT_FOUND' },
{ status: 404, headers: { 'X-Request-Id': requestId } }
)
// request.json() — parse POST body
export default async function handler(req: Request): Promise<Response> {
if (req.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}
// Validate Content-Type before parsing
const ct = req.headers.get('Content-Type') ?? ''
if (!ct.includes('application/json')) {
return Response.json({ error: 'Expected application/json' }, { status: 415 })
}
let body: unknown
try {
body = await req.json() // throws SyntaxError on invalid JSON
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
return Response.json({ received: body })
}
// Consuming the body stream — can only call once
// request.json() and request.text() both consume the stream
// Use request.clone() if you need to read the body in two places
const cloned = request.clone()
const rawText = await cloned.text() // log raw body
const parsed = await request.json() // parse original
// Headers API — inspect and set JSON-related headers
const accept = request.headers.get('Accept') // 'application/json'
const contentType = request.headers.get('Content-Type')
const responseHeaders = new Headers({
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=60',
'Access-Control-Allow-Origin': '*',
})
return new Response(JSON.stringify(data), { headers: responseHeaders })
// Streaming JSON with ReadableStream (NDJSON)
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
for (const item of largeDataset) {
controller.enqueue(encoder.encode(JSON.stringify(item) + '\n'))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson' },
})A key distinction: Response.json() was added to the Fetch API specification and is available in all modern edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy, and browsers). It is not available in older Node.js versions (added in Node.js 21) — if your code also runs in Node.js serverless contexts, check the runtime before using it or polyfill with the verbose form. The Headers API is case-insensitive for header name lookup: headers.get('content-type') and headers.get('Content-Type') return the same value.
Vercel Edge Functions: JSON Request and Response Handling
Vercel Edge Functions run in V8 Isolates at Vercel's global edge network and support two deployment patterns: App Router edge route handlers (app/api/route.ts with export const runtime = 'edge') and Edge Middleware (middleware.ts) for request inspection and modification. Both use next/server's NextRequest and NextResponse — supersets of the standard Fetch API Request and Response with Next.js-specific helpers.
// app/api/data/route.ts — Edge Route Handler
import { NextRequest, NextResponse } from 'next/server'
export const runtime = 'edge' // opt into V8 Isolate runtime
export async function GET(request: NextRequest): Promise<Response> {
const url = new URL(request.url)
const id = url.searchParams.get('id')
const data = { id, timestamp: Date.now(), region: process.env.VERCEL_REGION }
return NextResponse.json(data, { status: 200 })
}
export async function POST(request: NextRequest): Promise<Response> {
let body: { name: string; value: unknown }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
if (!body.name || typeof body.name !== 'string') {
return NextResponse.json({ error: 'name is required' }, { status: 422 })
}
return NextResponse.json({ created: true, name: body.name }, { status: 201 })
}
// middleware.ts — inspect and modify JSON in-flight
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// Add JSON content-type to all /api/* responses
if (pathname.startsWith('/api/')) {
const response = NextResponse.next()
response.headers.set('X-Edge-Region', process.env.VERCEL_REGION ?? 'unknown')
return response
}
// Block requests without a valid JSON body to /api/ingest
if (pathname === '/api/ingest' && request.method === 'POST') {
const ct = request.headers.get('Content-Type') ?? ''
if (!ct.includes('application/json')) {
return NextResponse.json({ error: 'JSON required' }, { status: 415 })
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/api/:path*'],
}
// Payload size limits — Vercel Edge
// Request body: 4 MB max
// Response body: 4 MB max
// CPU time: 50 ms (soft) — avoid large JSON.parse in middleware
// Memory: 128 MB per isolateEdge Middleware runs on every matching request before it reaches your route handler — keep JSON parsing in middleware lightweight. Avoid calling request.json() in middleware unless absolutely necessary, because the body stream is consumed and must be reconstructed before forwarding the request to the route. Instead, inspect JSON by cloning: const clone = request.clone(); const body = await clone.json(). The original request passed to NextResponse.next() still has its body intact. See the JSON in Next.js guide for App Router JSON patterns outside the edge runtime.
Cloudflare Workers: JSON with KV and D1
Cloudflare Workers use the fetch event handler pattern (or the newer export default { fetch } module syntax) and provide access to KV namespaces, D1 SQLite databases, Durable Objects, and R2 blob storage via environment bindings. KV is the most common JSON storage primitive — it stores string values, requiring JSON.stringify on write and JSON.parse on read (or the built-in type: 'json' option).
// wrangler.toml — declare KV namespace binding
// [[kv_namespaces]]
// binding = "JSON_CACHE"
// id = "abc123..."
// Worker module syntax (recommended)
interface Env {
JSON_CACHE: KVNamespace
DB: D1Database
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// ── KV: write JSON ────────────────────────────────────────
if (request.method === 'POST' && url.pathname === '/kv/set') {
const body = await request.json() as { key: string; data: unknown }
await env.JSON_CACHE.put(
body.key,
JSON.stringify(body.data),
{ expirationTtl: 3600 } // expire after 1 hour
)
return Response.json({ stored: true })
}
// ── KV: read JSON (manual parse) ──────────────────────────
if (url.pathname.startsWith('/kv/get/')) {
const key = url.pathname.replace('/kv/get/', '')
const raw = await env.JSON_CACHE.get(key)
if (!raw) return Response.json({ error: 'Not found' }, { status: 404 })
const data = JSON.parse(raw)
return Response.json(data)
}
// ── KV: read JSON (type option — skips manual parse) ──────
if (url.pathname.startsWith('/kv/json/')) {
const key = url.pathname.replace('/kv/json/', '')
const data = await env.JSON_CACHE.get(key, { type: 'json' })
if (!data) return Response.json({ error: 'Not found' }, { status: 404 })
return Response.json(data)
}
// ── D1: query JSON arrays with json_each() ────────────────
if (url.pathname === '/d1/tags') {
const tag = url.searchParams.get('tag') ?? ''
// D1 SQLite json_each() extracts rows from a JSON array column
const { results } = await env.DB.prepare(
`SELECT id, name FROM products
WHERE EXISTS (
SELECT 1 FROM json_each(products.tags) t WHERE t.value = ?
) LIMIT 20`
).bind(tag).all()
return Response.json({ results })
}
return Response.json({ error: 'Not found' }, { status: 404 })
},
}
// Durable Objects — strongly-consistent JSON state
// (KV is eventually consistent; Durable Objects are strongly consistent)
export class JsonStore {
state: DurableObjectState
constructor(state: DurableObjectState) {
this.state = state
}
async fetch(request: Request): Promise<Response> {
if (request.method === 'PUT') {
const body = await request.json() as Record<string, unknown>
// Durable Object storage: put/get store strings
await this.state.storage.put('data', JSON.stringify(body))
return Response.json({ saved: true })
}
if (request.method === 'GET') {
const raw = await this.state.storage.get<string>('data')
const data = raw ? JSON.parse(raw) : null
return Response.json({ data })
}
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}
}Cloudflare KV is eventually consistent with up to 60 seconds of read-after-write lag globally — writes propagate to all edges within 60 seconds but reads may return stale values. For JSON state that must be strongly consistent (shopping cart, counters, collaborative data), use Durable Objects instead — they guarantee linearizable reads and writes within a single Durable Object instance. Cloudflare D1 supports SQLite's full JSON function set: json(), json_each(), json_extract(), and json_object() — making it practical for storing semi-structured JSON data in a relational store without separate JSON parsing at the application layer.
Deno Deploy: JSON Responses and Fetch-Based Routing
Deno Deploy uses Deno.serve() as its entry point — a native HTTP server that accepts a handler function receiving a standard Request and returning a Response. JSON handling uses the same Web Fetch API primitives as Cloudflare Workers and Vercel, but Deno Deploy also provides Deno KV for persistent JSON storage with native JavaScript value support (no manual JSON.stringify required) and the URLPattern API for declarative route matching.
// main.ts — Deno Deploy entry point
// deno deploy --project=my-app main.ts
// ── Basic JSON handler ─────────────────────────────────────────
Deno.serve(async (request: Request): Promise<Response> => {
const url = new URL(request.url)
// URLPattern routing — declarative path matching
const userPattern = new URLPattern({ pathname: '/users/:id' })
const itemsPattern = new URLPattern({ pathname: '/items' })
// GET /users/:id — return JSON
const userMatch = userPattern.exec(url)
if (userMatch && request.method === 'GET') {
const userId = userMatch.pathname.groups.id
const data = { id: userId, name: 'Alice', createdAt: new Date().toISOString() }
return Response.json(data)
}
// POST /items — parse JSON body
if (itemsPattern.test(url) && request.method === 'POST') {
let body: { name: string; quantity: number }
try {
body = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
}
return Response.json({ created: true, item: body }, { status: 201 })
}
return Response.json({ error: 'Not found' }, { status: 404 })
})
// ── Deno KV — persistent JSON storage ─────────────────────────
// Deno KV stores JavaScript values natively — no JSON.stringify needed
const kv = await Deno.openKv()
// Write a JavaScript object directly (Deno KV serializes internally)
await kv.set(['users', 'alice'], { name: 'Alice', score: 42 })
// Read back a typed JavaScript object
const entry = await kv.get<{ name: string; score: number }>(['users', 'alice'])
console.log(entry.value?.name) // 'Alice'
// Atomic operations — read-modify-write for JSON counters
const result = await kv.atomic()
.check({ key: ['counter', 'views'], versionstamp: null })
.set(['counter', 'views'], { count: 1 })
.commit()
// List all keys with a prefix — paginated JSON records
const iter = kv.list<{ name: string }>({ prefix: ['users'] })
const users: { name: string }[] = []
for await (const entry of iter) {
users.push(entry.value)
}
return Response.json({ users })
// ── Deno permission model ──────────────────────────────────────
// Reading local JSON files requires --allow-read permission
// deno run --allow-read --allow-net main.ts
// In Deno Deploy, file system access is not available at runtime
// Bundle static JSON into your module instead:
import config from './config.json' assert { type: 'json' }
console.log(config.apiVersion) // static JSON imported at deploy timeDeno KV's native JavaScript value support eliminates the JSON.stringify/JSON.parse boilerplate required by Cloudflare KV. Deno KV serializes values using the V8 serialization format, supporting objects, arrays, numbers, booleans, Date, Uint8Array, and Map/Set — but not functions or class instances with methods. For JSON interoperability with external systems, explicitly use JSON.stringify before writing to ensure the stored format is portable. See the JSON caching strategies guide for caching patterns that work across all edge platforms.
Edge JSON Caching with Cache-Control and CDN
Caching JSON responses at the edge reduces origin load and cuts response latency to under 10 ms for cached requests. The primary caching mechanism is Cache-Control headers — s-maxage controls CDN/edge cache TTL while max-age controls browser cache TTL. Combine with stale-while-revalidate for zero-downtime cache refreshes where the CDN serves stale JSON while fetching a fresh copy in the background.
// Cache-Control patterns for edge JSON caching
// Public API data — 5 min edge cache, 1 min browser, 1 min SWR
return Response.json(data, {
headers: {
'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=60',
},
})
// User-specific data — never cache at edge
return Response.json(userData, {
headers: { 'Cache-Control': 'private, no-store' },
})
// CDN-only cache (does not affect browser cache)
return Response.json(data, {
headers: {
'Cache-Control': 'public, max-age=0', // browser: no cache
'CDN-Cache-Control': 'public, s-maxage=300', // CDN: 5 min
},
})
// ── Cloudflare Workers Cache API (programmatic caching) ────────
export default {
async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
const cache = caches.default
const cacheKey = new Request(request.url, { method: 'GET' })
// Check cache first
const cached = await cache.match(cacheKey)
if (cached) {
const response = new Response(cached.body, cached)
response.headers.set('X-Cache', 'HIT')
return response
}
// Fetch from origin and cache
const data = await fetchJsonFromDatabase()
const response = Response.json(data, {
headers: {
'Cache-Control': 'public, max-age=300',
'X-Cache': 'MISS',
},
})
// cache.put is async — use ctx.waitUntil to avoid blocking the response
ctx.waitUntil(cache.put(cacheKey, response.clone()))
return response
},
}
// ── Vercel edge cache tags — granular invalidation ─────────────
// Tag the response so you can purge by tag via Vercel API
return NextResponse.json(data, {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
'x-vercel-cache-tags': `product-${productId} category-${categoryId}`,
},
})
// Invalidate all responses tagged 'product-123' via Vercel API:
// POST https://api.vercel.com/v1/data-cache/purge?teamId=...
// Body: { "tags": ["product-123"] }stale-while-revalidate is particularly valuable for JSON API endpoints where freshness matters but strict consistency is not required — product listings, leaderboards, and configuration data are good candidates. The CDN serves the stale cached JSON immediately (sub-millisecond), then revalidates in the background. The user never sees a slow request, and the data is at most s-maxage + stale-while-revalidate seconds old. For data that must be fresh on every request (prices, inventory), set Cache-Control: no-cache which revalidates with the origin before serving (not the same as no-store).
Runtime Limitations Affecting JSON Processing
Edge functions run in WinterTC-compliant runtimes — V8 Isolates with a subset of browser Web APIs and no Node.js built-ins. Understanding these limitations prevents runtime errors when porting Node.js JSON processing code to the edge. The most common issues: using the Node.js crypto module for JSON signing, importing packages that depend on fs for JSON file loading, and using Buffer for binary-to-JSON conversion.
// ❌ Node.js crypto — NOT available at edge
// import { createHmac } from 'crypto'
// const sig = createHmac('sha256', secret).update(payload).digest('hex')
// ✅ Web Crypto API — available in all edge runtimes
async function signJson(payload: unknown, secret: string): Promise<string> {
const encoder = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign(
'HMAC',
keyMaterial,
encoder.encode(JSON.stringify(payload))
)
// Convert ArrayBuffer to hex string
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
// ❌ fs — NOT available at edge (no file system)
// import { readFileSync } from 'fs'
// const config = JSON.parse(readFileSync('./config.json', 'utf8'))
// ✅ Import JSON statically at build time
import config from './config.json' // bundled at deploy time, not runtime
// ❌ Buffer — NOT available at edge (Node.js only)
// const decoded = Buffer.from(base64, 'base64').toString('utf8')
// const obj = JSON.parse(decoded)
// ✅ TextDecoder for base64 JSON decoding
function decodeBase64Json(base64: string): unknown {
// atob() is available in edge runtimes
const binary = atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
const text = new TextDecoder().decode(bytes)
return JSON.parse(text)
}
// WinterTC compatibility check — test packages before deploying to edge
// Packages that are edge-compatible:
// zod, valibot, superjson (pure JS validation/serialization)
// date-fns (pure JS date library)
// jose (Web Crypto API JWT library)
// Packages that are NOT edge-compatible:
// jsonwebtoken (uses Node.js crypto)
// multer (uses Node.js streams and fs)
// most ORMs (use Node.js net/tls for database connections)
// Check edge compatibility: import { isEdgeRuntime } from 'next/dist/shared/lib/utils'
// Or use the runtime check at deploy: next build --debug
// CPU time limits — avoid heavy JSON processing at edge
// Vercel: 50 ms CPU time per request
// Cloudflare Workers: 10 ms (free) / 50 ms (paid) CPU time
// ✅ Light processing: parse small JSON, transform, return
// ❌ Heavy processing: JSON.parse on 10 MB payloads, sort large arraysLibrary compatibility is the most common edge deployment failure. Before adding a dependency to an edge function, check whether it uses Node.js built-ins: run node --experimental-vm-modules or check the package's exports field for an edge-light or browser condition. The jose library is the recommended replacement for jsonwebtoken at the edge — it implements JWT creation and verification using the Web Crypto API and is fully WinterTC-compliant. For JSON schema validation, zod and valibot are both edge-compatible.
JSON Streaming from Edge: ReadableStream and SSE
Streaming JSON from edge functions enables time-to-first-byte optimization for large datasets and real-time data delivery without buffering the full response. Edge runtimes support ReadableStream, WritableStream, and TransformStream from the Web Streams API. The two primary streaming patterns for JSON are NDJSON (newline-delimited JSON, one object per line) and Server-Sent Events (SSE, text/event-stream with data: prefixed JSON lines).
// ── NDJSON streaming — one JSON object per line ───────────────
export const runtime = 'edge'
export async function GET(): Promise<Response> {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const items = [
{ id: 1, name: 'Alpha' },
{ id: 2, name: 'Beta' },
{ id: 3, name: 'Gamma' },
]
for (const item of items) {
controller.enqueue(encoder.encode(JSON.stringify(item) + '\n'))
// Optional: await a tick to avoid blocking the isolate
await new Promise(r => setTimeout(r, 0))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-store',
},
})
}
// ── Server-Sent Events (SSE) — JSON data lines ─────────────────
export async function GET(request: Request): Promise<Response> {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// SSE format: "data: {JSON}
"
const sendEvent = (event: string, data: unknown) => {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`))
}
sendEvent('connected', { timestamp: Date.now() })
for (let i = 0; i < 5; i++) {
await new Promise(r => setTimeout(r, 1000))
sendEvent('update', { tick: i, value: Math.random() })
}
sendEvent('done', { message: 'stream complete' })
controller.close()
},
cancel() {
// Client disconnected — clean up resources
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-store',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // disable Nginx buffering
},
})
}
// ── TransformStream — modify JSON in-flight ────────────────────
// Proxy an upstream JSON stream, injecting metadata into each line
function createJsonTransformer(metadata: Record<string, unknown>) {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
let buffer = ''
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? '' // last partial line stays in buffer
for (const line of lines) {
if (!line.trim()) continue
try {
const obj = JSON.parse(line)
const enriched = { ...obj, ...metadata }
controller.enqueue(encoder.encode(JSON.stringify(enriched) + '\n'))
} catch {
// Pass through non-JSON lines
controller.enqueue(encoder.encode(line + '\n'))
}
}
},
flush(controller) {
if (buffer.trim()) {
// Handle final line without trailing newline
try {
const obj = JSON.parse(buffer)
controller.enqueue(encoder.encode(JSON.stringify({ ...obj, ...metadata }) + '\n'))
} catch {
controller.enqueue(encoder.encode(buffer))
}
}
},
})
}
// ── Streaming AI responses as NDJSON from edge ─────────────────
export async function POST(request: Request): Promise<Response> {
const { prompt } = await request.json() as { prompt: string }
// Forward the upstream stream directly to the client
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY ?? '',
'anthropic-version': '2023-06-01',
'anthropic-beta': 'messages-2023-12-15',
},
body: JSON.stringify({
model: 'claude-opus-4-5',
max_tokens: 1024,
stream: true,
messages: [{ role: 'user', content: prompt }],
}),
})
// Pipe upstream SSE stream directly to client
return new Response(upstream.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-store',
},
})
}SSE is preferred over WebSockets for one-directional JSON streaming from edge functions because SSE uses standard HTTP — no protocol upgrade required — and works through CDNs and proxies that support HTTP/1.1 chunked transfer encoding. Set X-Accel-Buffering: no to disable Nginx's response buffering when deploying behind a reverse proxy, otherwise chunks will be held until the buffer fills rather than being sent immediately. For bidirectional JSON communication, WebSockets require a persistent connection that some edge platforms do not support — check your platform's WebSocket support before architecting around it. See the JSON real-time sync guide for WebSocket and SSE patterns in depth.
Key Terms
- V8 Isolate
- A lightweight JavaScript execution context created by the V8 engine — the same engine used in Chrome and Node.js. Unlike a full Node.js process, a V8 Isolate has no access to operating system APIs (no file system, no network sockets), no shared memory with other isolates, and a minimal startup cost of under 1 ms. Edge function platforms (Vercel, Cloudflare Workers, Deno Deploy) create a new V8 Isolate per request (or reuse a warm isolate from a pool), enabling global deployment without the cold-start penalty of spinning up a full virtual machine or container. Each isolate has its own heap memory (typically 128-256 MB limit) and its own global scope — no global state is shared between requests.
- Web Fetch API
- The browser-standard HTTP API — originally specified for browser use — that has been adopted as the standard for edge function runtimes. It defines
fetch(),Request,Response,Headers, andBody(the mixin providing.json(),.text(), and.arrayBuffer()methods). TheResponse.json()static constructor is part of the Fetch API living standard. Edge runtimes implement the Fetch API instead of Node.js'shttp/httpsmodules, enabling the same code to run in browsers, edge functions, and WinterTC-compliant runtimes. The Fetch API uses Promises and theasync/awaitpattern throughout. - KV storage
- A key-value storage system optimized for global low-latency reads with eventual consistency. In the context of edge functions, KV storage (Cloudflare Workers KV, Vercel KV, Deno KV) stores arbitrary values — typically JSON strings — accessible from any edge location worldwide with read latency under 10 ms for cached values. Cloudflare KV stores string values (requiring
JSON.stringify/JSON.parse), while Deno KV stores native JavaScript values using V8 serialization. KV is eventually consistent: a write is globally propagated within 60 seconds, but reads at distant edge nodes may return stale values during that window. For strongly-consistent JSON state, use Durable Objects (Cloudflare) or coordinate reads to a single region. - WinterTC
- The Web-interoperable Runtimes Technical Committee — a standards group (under Ecma International) that defines the Minimum Common Web Platform API: the baseline set of Web APIs that all non-browser JavaScript runtimes (edge functions, server runtimes) must implement for cross-runtime compatibility. WinterTC-compliant runtimes include Cloudflare Workers, Deno, Bun, and Node.js 18+ (with some gaps). The baseline covers:
fetch,Request,Response,Headers,URL,URLSearchParams,TextEncoder/TextDecoder,ReadableStream/WritableStream/TransformStream,crypto.subtle,crypto.randomUUID(),setTimeout/clearTimeout, andqueueMicrotask. A library is "edge-compatible" when it only uses WinterTC baseline APIs. - stale-while-revalidate
- A
Cache-Controldirective that instructs CDN and browser caches to serve a stale (expired) cached response immediately while fetching a fresh copy from the origin in the background. The syntax isCache-Control: max-age=60, stale-while-revalidate=300— this means: serve from cache for up to 60 seconds; after 60 seconds, the cache is stale but the CDN serves the stale response for up to 300 more seconds while revalidating; after 360 seconds total, the cache is gone and must be fetched fresh. For JSON API responses, stale-while-revalidate eliminates the latency spike that occurs when a cached response expires: users always receive a fast cached response, and data staleness is bounded bymax-age + stale-while-revalidate. Supported by Cloudflare, Fastly, and Vercel Edge Network; not supported by AWS CloudFront. - Durable Object
- A Cloudflare Workers primitive that provides strongly-consistent, stateful storage for a single JavaScript object instance pinned to a specific Cloudflare location. Unlike KV (which is eventually consistent), a Durable Object processes all requests to it sequentially in a single-threaded execution context, guaranteeing linearizable reads and writes. JSON state stored in a Durable Object via
state.storage.put(key, value)(where value must be a string — useJSON.stringify) is immediately consistent: a write followed by a read always returns the written value. Durable Objects are identified by a name or ID, and all requests for the same ID are routed to the same Durable Object instance. They are suited for JSON state that requires strict consistency: counters, collaborative editing state, session data, and rate-limiting counters.
FAQ
How do I return a JSON response from a Vercel Edge Function?
Use the Response.json() static constructor: return Response.json({ key: "value" }, { status: 200 }). This sets Content-Type: application/json automatically and serializes the object. In Next.js App Router edge route handlers, add export const runtime = 'edge' to app/api/route.ts, then return Response.json(data) from your GET or POST handler. For Edge Middleware (middleware.ts), use NextResponse.json(data) from next/server. To include custom headers, pass them in the second argument: Response.json(data, { status: 201, headers: { "X-Custom": "value" } }). This replaces the verbose new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json" } }) pattern and is the idiomatic approach in all WinterTC-compliant edge runtimes.
What is the difference between edge functions and serverless functions for JSON processing?
Edge functions use V8 Isolates — lightweight JavaScript contexts that start in under 1 ms — while serverless functions (AWS Lambda, Vercel Serverless) spin up a full Node.js runtime with 100-500 ms cold-start latency. Edge functions eliminate cold-start delays for JSON API endpoints. The tradeoff: edge functions cannot use Node.js-specific APIs — no fs module, no Buffer, and limited crypto(use the Web Crypto API instead). Libraries must be WinterTC-compliant; packages using Node.js built-ins do not run at the edge. Edge functions also have smaller payload limits (Vercel: 4 MB request/response vs Lambda's 10 MB) and shorter CPU time limits (50 ms vs serverless's 15 minutes). Use edge functions for lightweight, latency-sensitive JSON APIs; use serverless functions for heavy JSON processing, database connections, or packages that require Node.js built-ins.
How do I store and retrieve JSON in Cloudflare Workers KV?
KV stores values as strings, so serialize with JSON.stringify before writing and deserialize with JSON.parse after reading. Write: await env.MY_KV.put('key', JSON.stringify({ data: "value" })). Read: const raw = await env.MY_KV.get('key'); const obj = raw ? JSON.parse(raw) : null. To skip manual parsing, use the type: 'json' option: const obj = await env.MY_KV.get('key', { type: "json" }). For expiring data, pass expirationTtl: await env.MY_KV.put('session:abc', JSON.stringify(session), { expirationTtl: 3600 }). KV is eventually consistent — reads may return stale values for up to 60 seconds after a write. For strongly-consistent JSON state, use Durable Objects instead. Declare the KV binding in wrangler.toml under [[kv_namespaces]] and access it via env.MY_KV in your handler.
What Web APIs are available for JSON processing in edge functions?
Edge functions implement the WinterTC Minimum Common Web Platform API. For JSON processing, these are universally available: JSON.parse() and JSON.stringify(); Response.json() and request.json() (Fetch API); Headers for Content-Type inspection; TextEncoder and TextDecoder for string-to-binary conversion; crypto.subtle for HMAC-SHA256 JSON signing; ReadableStream and TransformStream for streaming JSON; and URL and URLSearchParams for query parameter parsing. Not available: fs (no file system), Buffer (use Uint8Array and TextEncoder), require() (no CommonJS), and most Node.js built-in modules. Use crypto.subtle.sign() with HMAC for JSON signing instead of the Node.js crypto module, and the jose library for JWT operations at the edge.
How do I parse a JSON request body in a Cloudflare Worker?
Call await request.json() inside your fetch handler — it reads and parses the body stream in one step. Always wrap it in try/catch because it throws SyntaxError on invalid JSON. Full pattern: let body; try { body = await request.json() } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }) }. Important: request.json() consumes the body stream — you can only call it once. If you need to read the body in multiple places, clone the request first: const cloned = request.clone(); const raw = await cloned.text(); const parsed = await request.json(). Also validate the Content-Type header before parsing: return a 415 error if it does not include application/json. Validate the parsed body shape with a runtime validator like zod (edge-compatible) before trusting its structure.
How do I cache JSON responses at the edge?
Set Cache-Control headers on your JSON responses. For public API data: Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60 caches 1 minute in browsers and 5 minutes at the CDN edge with a 1-minute stale-while-revalidate window. For Cloudflare Workers, use the Cache API for programmatic control: const cached = await caches.default.match(request); if (cached) return cached, then ctx.waitUntil(caches.default.put(request, response.clone())) to store the response without blocking. For Vercel Edge, set CDN-Cache-Control: public, s-maxage=300 to target only the edge cache. Never cache user-specific JSON — use Cache-Control: private, no-store for authenticated responses. Add Vary: Accept-Encoding when serving compressed JSON to prevent serving a gzipped response to a client that does not accept gzip.
What are the JSON payload size limits for edge functions?
Limits vary by platform. Vercel Edge Functions: 4 MB request body and 4 MB response body on all plans. Cloudflare Workers free plan: 100 MB request / 25 MB response; paid plan: 128 MB request / 25 MB response. Deno Deploy: 128 MB for both request and response. CPU time limits also constrain JSON processing: Vercel Edge 50 ms, Cloudflare Workers 10 ms (free) / 50 ms (paid). For large JSON payloads, consider streaming responses as NDJSON to avoid buffering the full payload; paginating API responses to keep individual JSON payloads small; offloading large JSON to blob storage (R2, S3) and returning a signed URL; or compressing with gzip (Cloudflare Workers auto-compress; add Content-Encoding: gzip on Vercel). Avoid JSON.parse on payloads approaching the CPU time limit — parsing a 4 MB JSON file can consume 20-40 ms of CPU time, leaving little headroom for business logic.
How do I stream JSON responses from an edge function?
Use ReadableStream for chunked JSON responses. For NDJSON (one JSON object per line): create a ReadableStream with a start(controller) function that calls controller.enqueue(encoder.encode(JSON.stringify(item) + '\n')) for each item and controller.close() when done; return new Response(stream, { headers: { "Content-Type": "application/x-ndjson" } }). For Server-Sent Events (SSE) with JSON payloads, set Content-Type: text/event-stream and format each event as 'data: ' + JSON.stringify(data) + '\n\n'. For streaming AI API responses (Anthropic, OpenAI), forward the upstream ReadableStream directly: const upstream = await fetch(aiEndpoint, opts); return new Response(upstream.body, { headers: { "Content-Type": "text/event-stream" } }). Set X-Accel-Buffering: no to disable Nginx buffering when behind a reverse proxy.
Further reading and primary sources
- Vercel Edge Functions Documentation — Official Vercel docs for Edge Functions, runtime capabilities, and payload limits
- Cloudflare Workers KV Documentation — Cloudflare KV API reference, consistency model, and JSON storage patterns
- Deno Deploy Documentation — Deno Deploy entry points, Deno KV, and Fetch API usage
- WinterTC Minimum Common Web Platform API — WinterTC baseline Web API specification for cross-runtime compatibility
- Web Fetch API Living Standard — WHATWG Fetch API specification including Response.json() and request.json()