JSON in Edge Runtime: Workers, Next.js Edge, and Vercel
Last updated:
Edge runtimes — Cloudflare Workers, Next.js Edge Runtime, and Vercel Edge Functions — run JavaScript in V8 isolates close to users. They support JSON.parse(), JSON.stringify(), and Response.json() natively, but block Node.js APIs like fs, Buffer, and most npm packages that use native bindings.
Edge functions cold-start in under 1 ms compared to 100+ ms for a Node.js Lambda because they skip the Node.js process startup and share a running V8 engine. The trade-off: no native modules, a 1–128 MB memory limit depending on the platform, and no filesystem access.
This guide covers JSON in Cloudflare Workers, Next.js Edge Route Handlers, and Vercel Edge Middleware — including request.json(), Response.json(), streaming JSON, and edge-compatible validation with Zod schema validation.
What Are Edge Runtimes?
An edge runtime executes JavaScript in V8 isolates deployed at network edge locations worldwide — typically within 50 ms of any user. Unlike a Node.js Lambda that boots a full Node.js process per cold start, a V8 isolate reuses the already-running V8 engine and starts a new JavaScript context in microseconds. This eliminates cold-start latency for JSON-serving APIs.
Edge runtimes implement the WinterCG (Web Interoperability Runtime Community Group) subset of browser APIs: fetch, Request, Response, URL, Headers, crypto.subtle, TextEncoder, TextDecoder, ReadableStream, and WritableStream. The full JSON API — JSON.parse() and JSON.stringify() — is available with the same V8 fast path as Node.js. What is not available: fs, net, http, Buffer, process, and npm packages that depend on Node.js built-ins.
| Platform | Runtime | Memory limit | CPU limit |
|---|---|---|---|
| Cloudflare Workers | V8 isolate | 128 MB | 10 ms (free) / 30 s (paid) |
| Next.js Edge Runtime | V8 isolate (on Vercel) | 128 MB | No hard limit (soft 25 s) |
| Vercel Edge Functions | V8 isolate | 128 MB | No hard limit (soft 25 s) |
| Deno Deploy | V8 isolate | 512 MB | No hard limit |
Response.json() and request.json() at the Edge
Response.json(data, init?) is a static constructor standardized by WinterCG. It serializes data with JSON.stringify and sets Content-Type: application/json; charset=UTF-8 automatically. Pass an optional init object to control the HTTP status code and additional headers. This method is available in Cloudflare Workers, Next.js Edge Runtime, Vercel Edge Functions, and Deno Deploy — no framework import needed.
// Works in Cloudflare Workers, Next.js edge route handler, and Vercel edge function
// Basic JSON response — Content-Type set automatically
return Response.json({ ok: true, ts: Date.now() })
// Custom status code
return Response.json({ error: 'Not Found' }, { status: 404 })
// Custom headers alongside JSON
return Response.json(
{ data: 'hello' },
{
status: 200,
headers: {
'Cache-Control': 'public, max-age=60',
'X-Edge-Region': 'iad1',
},
}
)
// Parsing the request body
export async function POST(request: Request) {
let body: unknown
try {
body = await request.json() // reads stream, calls JSON.parse internally
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
return Response.json({ received: body }, { status: 201 })
}Avoid new Response(JSON.stringify(data)) — you must set Content-Type: application/json manually and it is easy to omit. Response.json() handles this in one call and is part of the WinterCG standard adopted by all major edge platforms as of 2023.
JSON in Cloudflare Workers
JSON in Cloudflare Workers uses the standard fetch handler pattern. Workers export a default object with a fetch method that receives a Request and must return a Response. KV storage serializes JSON as strings; D1 (SQLite) supports JSON columns via the JSON1 extension.
// src/index.ts — Cloudflare Worker
interface Env {
KV: KVNamespace // Cloudflare KV binding
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
// --- Return JSON ---
if (url.pathname === '/api/ping') {
return Response.json({ ok: true, ts: Date.now() })
}
// --- Parse JSON body ---
if (request.method === 'POST' && url.pathname === '/api/items') {
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 })
}
return Response.json({ created: true, body }, { status: 201 })
}
// --- Read JSON from KV ---
if (url.pathname.startsWith('/api/cache/')) {
const key = url.pathname.slice('/api/cache/'.length)
// Built-in { type: 'json' } calls JSON.parse internally
const value = await env.KV.get(key, { type: 'json' })
if (!value) return Response.json({ error: 'Not Found' }, { status: 404 })
return Response.json(value)
}
// --- Write JSON to KV ---
if (request.method === 'PUT' && url.pathname.startsWith('/api/cache/')) {
const key = url.pathname.slice('/api/cache/'.length)
const body = await request.json()
await env.KV.put(key, JSON.stringify(body), { expirationTtl: 3600 })
return Response.json({ stored: true })
}
return Response.json({ error: 'Not Found' }, { status: 404 })
},
}KV stores up to 25 MB values as strings or ArrayBuffers. Always serialize with JSON.stringify on write and deserialize with the { type: "json" } option (or JSON.parse) on read. For queryable JSON fields, store them in D1 TEXT columns and query with json_extract().
Next.js Edge Route Handlers
Next.js App Router Route Handlers run in the Node.js runtime by default. Add export const runtime = 'edge' to a Route Handler file to opt in to the Edge Runtime — the handler runs in a V8 isolate at Vercel edge locations. Edge Route Handlers use the same Request/Response Web API as Cloudflare Workers; NextRequest (from next/server) extends the standard Request and also provides .json().
// app/api/edge-items/route.ts
export const runtime = 'edge' // <-- opt in to Edge Runtime
import { type NextRequest } from 'next/server'
import { z } from 'zod'
const ItemSchema = z.object({
name: z.string().min(1).max(100),
value: z.number(),
})
// GET — return JSON
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = Number(searchParams.get('page') ?? '1')
const items = [{ id: 1, name: 'alpha', value: 42 }] // from DB or cache
return Response.json({ page, items })
}
// POST — parse and validate JSON body
export async function POST(request: NextRequest) {
let raw: unknown
try {
raw = await request.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}
const result = ItemSchema.safeParse(raw)
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.flatten().fieldErrors },
{ status: 422 }
)
}
// result.data is fully typed: { name: string; value: number }
return Response.json({ created: true, item: result.data }, { status: 201 })
}Next.js Edge Route Handlers do not support fs, Buffer, or packages with native bindings. For streaming responses, use a ReadableStream — covered in the Streaming JSON section below. See the full guide on JSON in Next.js API routes for the Node.js runtime equivalent.
Edge-Compatible JSON Validation
Not all validation libraries work at the edge. Ajv uses new Function() for code generation, which is blocked in most edge runtimes for security reasons. Zod is pure TypeScript — no native bindings, no dynamic code generation — and is the recommended choice for edge validation. Valibot is a tree-shakeable alternative with a smaller bundle size.
// npm install zod
import { z } from 'zod'
// --- Schema definition ---
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(0).max(120),
role: z.enum(['admin', 'user', 'guest']),
metadata: z.record(z.string(), z.unknown()).optional(),
})
// Infer the TypeScript type from the schema
type User = z.infer<typeof UserSchema>
// --- safeParse: never throws ---
export async function POST(request: Request): Promise<Response> {
let raw: unknown
try {
raw = await request.json()
} catch {
return Response.json({ error: 'Body is not valid JSON' }, { status: 400 })
}
const result = UserSchema.safeParse(raw)
if (!result.success) {
// result.error.flatten().fieldErrors: Record<string, string[]>
return Response.json(
{
error: 'Validation failed',
fields: result.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
const user: User = result.data // fully typed, safe to use
// ... persist user
return Response.json({ created: true, email: user.email }, { status: 201 })
}
// --- Valibot alternative (smaller bundle) ---
// npm install valibot
import * as v from 'valibot'
const VUserSchema = v.object({
email: v.pipe(v.string(), v.email()),
age: v.pipe(v.number(), v.integer(), v.minValue(0)),
})
const vResult = v.safeParse(VUserSchema, raw)
if (!vResult.success) {
return Response.json({ error: 'Invalid', issues: vResult.issues }, { status: 422 })
}| Library | Edge compatible | Bundle size | Notes |
|---|---|---|---|
| Zod | Yes | ~13 KB minified | Recommended; great TypeScript DX |
| Valibot | Yes | ~2 KB (tree-shaken) | Smallest bundle; modular API |
| Ajv | No | ~30 KB | Uses new Function() — blocked at edge |
| Yup | Partial | ~20 KB | Some features may not work at edge |
Streaming JSON in Edge Runtimes
Edge runtimes support the WHATWG Streams API — ReadableStream, WritableStream, and TransformStream. Use ReadableStream with a TextEncoder to stream newline-delimited JSON (NDJSON) where each line is a complete JSON object. For browser clients, wrap NDJSON in Server-Sent Events (SSE) with data: prefixes and Content-Type: text/event-stream.
// Edge function — streaming NDJSON
export const runtime = 'edge'
export async function GET(): Promise<Response> {
const items = [
{ id: 1, name: 'alpha' },
{ id: 2, name: 'beta' },
{ id: 3, name: 'gamma' },
]
const encoder = new TextEncoder()
// ReadableStream: each chunk is a JSON line ending with '\n'
const stream = new ReadableStream({
async start(controller) {
for (const item of items) {
const line = JSON.stringify(item) + '\n'
controller.enqueue(encoder.encode(line))
// Simulate async work (e.g., DB fetch per batch)
await new Promise(r => setTimeout(r, 50))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
'X-Content-Type-Options': 'nosniff',
},
})
}
// Edge function — SSE (Server-Sent Events) with JSON payloads
export async function GET_SSE(): Promise<Response> {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const events = [
{ type: 'update', payload: { count: 1 } },
{ type: 'update', payload: { count: 2 } },
{ type: 'done', payload: {} },
]
for (const event of events) {
// SSE format: "data: <json>\n\n"
const line = `data: ${JSON.stringify(event)}\n\n`
controller.enqueue(encoder.encode(line))
await new Promise(r => setTimeout(r, 100))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
// Client-side NDJSON consumer
async function consumeNdjson(url: string) {
const response = await fetch(url)
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? '' // keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) console.log(JSON.parse(line))
}
}
}NDJSON over SSE is the standard pattern for streaming JSON from edge AI APIs (OpenAI, Anthropic) and works in all modern browsers via EventSource or the Fetch streaming API.
Edge Runtime Limits and Workarounds
Edge runtimes impose tighter constraints than Node.js Lambda. The most common issues when migrating JSON-heavy workloads: package incompatibility (native bindings), CPU time limits on large JSON transformations, and memory limits with very large JSON payloads. Use the Next.js Edge Runtime compatibility checker (next build with --debug) or Cloudflare's wrangler dev to surface incompatible imports before deployment.
// next.config.ts — check bundle sizes and edge compatibility
import type { NextConfig } from 'next'
const config: NextConfig = {
experimental: {
// Warns about packages that may not work in edge runtime
serverComponentsExternalPackages: ['heavy-node-package'],
},
}
export default config
// --- Workaround: lazy import Node.js-only code in non-edge route ---
// app/api/node-only/route.ts (Node.js runtime — default)
export const runtime = 'nodejs' // explicit; default when omitted
// app/api/edge/route.ts (Edge runtime)
export const runtime = 'edge'
// --- Workaround: replace Buffer with TextEncoder/Uint8Array ---
// Node.js:
const buf = Buffer.from(jsonString, 'utf-8')
// Edge-compatible:
const bytes = new TextEncoder().encode(jsonString)
// --- Workaround: split large JSON processing across edge + Node.js ---
// Edge route: validate, auth-check, then forward to Node.js worker
export async function POST(request: Request): Promise<Response> {
// Fast auth check at edge
const token = request.headers.get('Authorization')
if (!token) return Response.json({ error: 'Unauthorized' }, { status: 401 })
// Forward validated request to Node.js origin for heavy processing
const upstream = await fetch('https://api.internal/process', {
method: 'POST',
headers: request.headers,
body: request.body, // stream the body through
})
return upstream // proxy the response
}
// --- CPU time: avoid large JSON.stringify in tight loops ---
// Instead of building one giant JSON object, stream NDJSON (see streaming section)
// --- Memory: parse large JSON incrementally with a streaming parser ---
// npm install @streamparser/json (edge-compatible, no native deps)
import { JSONParser } from '@streamparser/json'
const parser = new JSONParser({ paths: ['$.items.*'] })
parser.onValue = ({ value }) => console.log(value) // process each item
// Feed chunks: parser.write(chunk)For packages that use eval() or new Function(), there is no workaround at the edge — switch to an edge-compatible alternative (e.g., Zod instead of Ajv, jose instead of jsonwebtokenfor JWT operations). The Hono JSON API guide covers building a fully edge-compatible JSON API with Hono, which tree-shakes to under 12 KB.
Definitions
- Edge runtime
- An execution environment that runs JavaScript in V8 isolates at network edge locations close to users, rather than in a centralized server region. Edge runtimes implement the WinterCG Web Standard API subset —
fetch,Request,Response,crypto.subtle,TextEncoder,ReadableStream— but not Node.js built-in modules. Examples include Cloudflare Workers, Next.js Edge Runtime on Vercel, Vercel Edge Functions, Deno Deploy, and Fastly Compute. - V8 isolate
- A sandboxed JavaScript execution context within the V8 engine process. Each isolate has its own heap and global object but shares the compiled V8 engine binary. Isolates start in under 1 ms because they do not boot a new operating system process. All standard JavaScript globals —
JSON.parse,JSON.stringify,Request,Response,crypto— are available. Node.js-specific C++ bindings are not. - Cold start
- The latency incurred when an edge function or serverless function must initialize before handling its first request. A Node.js Lambda cold start takes 100–500 ms because it boots a Node.js process. A V8 isolate cold start takes under 1 ms because the V8 engine is already running and only a new JavaScript context is created. Edge runtimes maintain warm isolates for frequently invoked functions, so cold starts are rare in production for high-traffic routes.
- Response.json()
- A static constructor on the
Responseclass standardized by the WinterCG fetch specification. It callsJSON.stringify(data)and creates aResponsewithContent-Type: application/json; charset=UTF-8set automatically. Signature:Response.json(data: unknown, init?: ResponseInit): Response. Theinitparameter acceptsstatus,statusText, andheaders. Available in all major edge runtimes since 2023 and in browsers since 2023. - Web Crypto API
- The browser-standard cryptography API exposed as
crypto.subtlein all edge runtimes. Supports HMAC-SHA256 signing and verification, AES-GCM encryption, RSA-PSS signing, ECDH key agreement, SHA-256/384/512 hashing, and random byte generation (crypto.getRandomValues). In edge runtimes,crypto.subtlereplaces Node.js'scryptomodule for signing JSON payloads, generating tokens, and verifying webhooks — no npm package required. - KV store
- A globally distributed key-value store for edge functions. Cloudflare KV stores string or ArrayBuffer values up to 25 MB per key. It is eventually consistent — writes propagate to all regions in approximately 60 seconds. JSON must be serialized with
JSON.stringifybefore writing and parsed withJSON.parse(or the built-in{ type: "json" }option) after reading. Vercel Edge Config is a similar key-value store optimized for configuration data with sub-millisecond reads. - NDJSON over SSE
- A streaming pattern that sends newline-delimited JSON (NDJSON) — one JSON object per line — over a Server-Sent Events connection. Each SSE event has the format
data: {"key":"value"}\n\n. The client reads the stream incrementally and parses each line independently as it arrives. This pattern is used by OpenAI, Anthropic, and other AI APIs for streaming completions and is fully supported in edge runtimes viaReadableStreamandTextEncoder.
FAQ
What JSON APIs are available in Cloudflare Workers?
Cloudflare Workers support the full V8 JSON API: JSON.parse(), JSON.stringify(), Response.json(data, init?), and request.json(). Response.json() serializes a value and sets Content-Type: application/json automatically. The Web Crypto API (crypto.subtle) is also available for HMAC-SHA256 signing JSON without any npm package. Node.js modules — fs, Buffer, http — are not available.
How do I parse a JSON request body in a Next.js Edge Route Handler?
Add export const runtime = 'edge' at the top of the Route Handler file, then call await request.json() inside the handler. Wrap it in try/catch because it throws SyntaxError on invalid JSON — return a 400 response in the catch block. NextRequest from next/server extends the standard Request and also provides .json(). A body stream can only be consumed once — use request.clone().json() for a secondary read.
Can I use Ajv for JSON Schema validation in an edge runtime?
No. Ajv uses new Function() for code generation, which is blocked in most edge runtimes for security reasons. Use Zod instead — it is pure TypeScript with no native dependencies. Call schema.safeParse(body) to validate without throwing. Valibot is a tree-shakeable alternative (~2 KB) that also works at the edge.
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 a second argument to set the status code: Response.json({ error: "Not Found" }, { status: 404 }). Avoid new Response(JSON.stringify(data)) — you must add Content-Type manually. Response.json() is available in all edge runtimes and is part of the WinterCG standard.
What is the difference between Next.js Edge Runtime and Node.js runtime?
The Edge Runtime runs in a V8 isolate — cold start under 1 ms, supports Web Standard APIs, no Node.js built-ins. The Node.js runtime runs in a full Node.js process — cold start 100–500 ms, supports all npm packages including native modules, fs, Buffer, and process. Choose the Edge Runtime for low-latency, globally distributed JSON handlers. Choose the Node.js runtime for database clients with native bindings, filesystem access, or packages that use Node.js globals.
How do I stream JSON data from an edge function?
Use a ReadableStream with a TextEncoder to stream NDJSON (one JSON object per line). Set Content-Type: application/x-ndjson. For browser SSE clients, use Content-Type: text/event-stream and prefix each line with data: followed by two newlines. On the client, read the stream with response.body.getReader() and split chunks on \n boundaries. Buffer incomplete lines across chunks.
Can I use fs.readFile in an edge runtime?
No. Edge runtimes run in V8 isolates without filesystem access — the Node.js fs module is not available. Workarounds: embed static JSON directly in source code (import data from './data.json' works via module bundling), fetch it from a URL at request time, or store it in edge-compatible storage (Cloudflare KV, R2, or Vercel Edge Config).
How do I sign a JSON payload with HMAC in an edge runtime?
Use crypto.subtle — the Web Crypto API available in all edge runtimes without any npm package. Import the key: await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ['sign']). Sign: await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(JSON.stringify(payload))). Encode the result as hex or base64 for transmission. See the full guide on how to sign JSON with HMAC.
Further reading and primary sources
- WinterCG Fetch Standard — Web-interoperable runtimes community group fetch specification
- Next.js Edge Runtime Docs — Official Next.js Edge and Node.js runtimes documentation
- Cloudflare Workers Runtime APIs — Cloudflare Workers supported runtime APIs
- Web Crypto API (MDN) — MDN reference for the Web Crypto API available in edge runtimes