Edge Runtime Fundamentals & Platform Constraints
Edge runtimes execute JavaScript in lightweight V8 isolates deployed at Points of Presence (PoPs) worldwide. The critical difference from traditional serverless is the execution environment: no file system, no raw TCP sockets, no Node.js built-ins—only a curated subset of browser-compatible Web APIs. Every request is stateless, every resource threshold is a hard limit enforced at the JavaScript engine level (not the OS level), and exceeding those limits terminates execution immediately with a 502 or 504 response.
This constraint-first model eliminates container cold-start overhead but forces architectural discipline. The guides in this section assume you are designing around platform limits, not abstracting them away. They cover the full surface of edge constraints: the supported Web API matrix, the Vercel Edge versus Cloudflare Workers trade-off, cold-start mitigation, memory and CPU limits, polyfill strategies for Node.js APIs, and bundle optimization. Once the runtime is understood, the companion domains on middleware chain architecture and edge caching and CDN integration build composable request pipelines and cache layers on top of it.
Execution Model
The edge request lifecycle runs in under 5 ms for pure routing logic:
- DNS resolves to the nearest PoP.
- A reverse proxy intercepts the HTTP request.
- A pre-warmed V8 isolate evaluates the handler.
- The isolate returns a
Responseobject; the connection is finalized.
Isolates share physical hardware across tenants but maintain strict memory and CPU boundaries enforced by the V8 engine. No shared state leaks between requests. Global variables reset between isolate invocations unless the platform explicitly reuses a warm isolate (Cloudflare Workers does this; Vercel does not guarantee it).
The provider-agnostic handler signature:
export type EdgeMiddleware = (
req: Request,
ctx: ExecutionContext
) => Promise<Response | void>;
export function createRouter(middlewares: EdgeMiddleware[]) {
return async (req: Request, ctx: ExecutionContext) => {
for (const middleware of middlewares) {
const result = await middleware(req, ctx);
if (result instanceof Response) return result;
}
return new Response('Not Found', { status: 404 });
};
}
This enforces separation of concerns: routing logic runs at the edge; heavy computation or database transactions are deferred to regional or origin services.
Isolate Lifecycle and Global Scope
An isolate is not a process. The platform keeps one V8 engine resident per machine and creates isolates inside it — a heap, a global object, and a compiled script — which is why the transition from nothing to running your code is measured in hundreds of microseconds rather than hundreds of milliseconds. What that transition still has to do is evaluate your module’s top-level code, exactly once, before the first request is dispatched into it.
That single evaluation is the part most teams under-budget. Every module-scope statement — building a URLPattern, compiling a regular expression, decoding a base64 config blob, constructing a client object — runs during evaluation and is paid again on every cold evaluation. Cold evaluations are more frequent than the sub-millisecond warm figure implies: each PoP evaluates the script independently, so a globally distributed deployment performs the work hundreds of times, and an isolate is discarded on redeploy, after an idle period, and under machine memory pressure.
The lifecycle dictates what may legitimately live at module scope. Safe: values derived only from code and environment — compiled patterns, lookup tables, a JWKS cache keyed by issuer. Unsafe: anything derived from a request. A warm isolate serves thousands of unrelated users, so a module-level let currentUser is a cross-request data leak waiting for a concurrency spike, and because requests are not sticky to an isolate, a module-level counter is not a rate limiter — it is a per-isolate approximation that resets without warning.
Cloudflare additionally forbids I/O during top-level evaluation: a fetch in module scope throws rather than blocking the deploy. The lazy singleton is the sanctioned workaround, moving the network call into the first request while still sharing the result with every subsequent one:
// Evaluated once per isolate — derived only from code and env, so it is safe
const ROUTE_PATTERN = new URLPattern({ pathname: '/api/:version/:resource' });
const JWKS = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL!));
// Deferred: built on first use, then reused for the life of this isolate
let priceTable: Promise<Map<string, number>> | null = null;
function getPriceTable(): Promise<Map<string, number>> {
priceTable ??= fetch(process.env.PRICE_URL!)
.then((res) => res.json() as Promise<Record<string, number>>)
.then((json) => new Map(Object.entries(json)));
return priceTable;
}
Caching the promise rather than the resolved value matters: two concurrent requests arriving at a freshly evaluated isolate would otherwise both see null and both issue the fetch. Storing the in-flight promise collapses that thundering herd to a single subrequest, and a rejected promise must be cleared (priceTable = null in a catch) or the isolate will serve the failure until it is evicted.
Resource Boundaries
| Provider | Memory cap | CPU per request | Execution timeout |
|---|---|---|---|
| Cloudflare Workers | 128 MB | 10 ms (free) / 30 s default, up to 5 min (paid) synchronous CPU | 30 s wall-clock |
| Vercel Edge Middleware | 128 MB | — (wall-clock limit applies) | 1000 ms wall-clock |
| Netlify Edge Functions | 512 MB | — | 50 s wall-clock |
CPU quotas apply to synchronous computation. I/O wait (outbound fetch, KV reads) does not count against the CPU budget on Cloudflare but does count against wall-clock time.
Design patterns that respect these ceilings:
export async function handleRequest(req: Request, ctx: ExecutionContext) {
const MAX_PAYLOAD = 5 * 1024 * 1024; // 5 MB
const contentLength = Number(req.headers.get('content-length') || 0);
if (contentLength > MAX_PAYLOAD) {
return new Response('Payload exceeds edge limit', { status: 413 });
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 25_000);
try {
const response = await processRequest(req, { signal: controller.signal });
return response;
} catch (err) {
if ((err as DOMException).name === 'AbortError') {
return new Response('Execution timeout exceeded', { status: 504 });
}
ctx.waitUntil(logError(err));
return new Response('Internal Server Error', { status: 500 });
} finally {
clearTimeout(timeoutId);
}
}
ctx.waitUntil() schedules background work after the response is sent. Use it for logging and cache warming—never for work that must complete before the response.
Supported API Surface
Edge runtimes expose a WHATWG-compliant API surface:
- Fetch stack:
fetch,Request,Response,Headers,Body - URL handling:
URL,URLSearchParams,URLPattern - Streams:
ReadableStream,WritableStream,TransformStream - Crypto:
crypto.subtle(WebCrypto),crypto.randomUUID() - Encoding:
TextEncoder,TextDecoder,atob,btoa - Timers:
setTimeout,clearTimeout,setInterval(within execution window) - Caching:
caches.open()/CacheAPI (Cloudflare; limited on others)
Absent: fs, net, tls, child_process, path, Node.js crypto module, process.exit. See Supported Web APIs in Edge Runtimes for a full per-provider matrix.
The absence of raw sockets has a consequence that surprises teams porting from a regional service: most database drivers do not work. PostgreSQL, MySQL, MongoDB, and Redis clients all speak binary protocols over TCP, and none of them can be polyfilled onto fetch. The workable options are an HTTP-fronted database driver, a connection-pooling proxy that exposes an HTTP or WebSocket endpoint, or keeping the query on a regional function the edge calls with fetch. Choosing a library for edge use therefore starts with a protocol question, not a feature comparison — a package that is smaller, faster, and better documented is still unusable if it opens a socket.
The same reasoning applies to cryptography. crypto.subtle is asynchronous and promise-based, whereas the Node crypto module is largely synchronous, so a library ported naively will either fail at import or block on an API that does not exist. Libraries built for the Web Crypto shape — jose for tokens, the platform crypto.randomUUID() for identifiers, crypto.subtle.digest for hashing — avoid both the polyfill weight and the runtime failure.
Caching Architecture
Edge caching operates across two layers:
HTTP response caching — Cache-Control, CDN-Cache-Control, and Surrogate-Key headers control how PoPs store and serve responses. Use stale-while-revalidate to serve stale content immediately while revalidating asynchronously.
KV / Durable Objects — programmatic key-value storage with different consistency guarantees:
| Mechanism | Latency | Consistency | Suitable For |
|---|---|---|---|
HTTP Cache-Control |
< 10 ms | Strong (per PoP) | Static assets, public API responses |
| Edge KV (e.g., Cloudflare KV) | 10–50 ms | Eventually consistent | Feature flags, session tokens |
| Durable Objects | 5–20 ms | Strongly consistent (single region) | Rate limiting, real-time state |
KV stores use eventual consistency across PoPs. Writes may take seconds to propagate globally. Use origin shielding for strongly consistent writes: route all mutations through a designated primary region while reads are served from the nearest edge cache.
The consistency model is not an implementation detail you can paper over. Eventual consistency means a user who updates a setting and immediately reloads may be served the previous value from a different PoP — read-your-writes is not guaranteed. Where that matters, write through to a strongly consistent store and set a short-lived cookie or header carrying the new value, so the next request can be served correctly from the client’s own state while the propagation completes. Where it does not matter — feature flags, published content, rate-limit hints — eventual consistency is exactly the right trade, because it buys single-digit-millisecond reads at every PoP.
The second decision is what belongs in HTTP caching versus programmatic storage. HTTP caching is the cheaper mechanism by a wide margin: the PoP answers from its own cache without invoking your isolate at all, so a cache hit costs zero CPU and zero subrequests. Programmatic storage always runs your code first. Push as much as possible into the HTTP layer with correct Cache-Control and cache-key design, and reserve KV or Durable Objects for state that genuinely cannot be expressed as a cacheable response — per-user counters, coordination, and anything that must be read and written in the same request. The edge caching and CDN integration domain covers the cache-key and invalidation mechanics that decide whether the HTTP layer can carry the load.
JWT Verification at the Perimeter
Validating tokens at the edge eliminates round-trips to auth services. The jose library is tree-shakeable and works in all edge runtimes without Node.js polyfills:
import { createRemoteJWKSet, jwtVerify } from 'jose';
export async function authMiddleware(req: Request, ctx: ExecutionContext) {
const authHeader = req.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const token = authHeader.slice(7);
const JWKS = createRemoteJWKSet(new URL(process.env.AUTH_JWKS_URL!));
try {
const { payload } = await jwtVerify(token, JWKS, { algorithms: ['RS256'] });
const headers = new Headers(req.headers);
headers.set('X-User-ID', payload.sub as string);
headers.set('X-User-Role', payload['role'] as string);
return new Response(null, { status: 200, headers });
} catch {
return new Response('Invalid or expired token', { status: 403 });
}
}
Worked Example: A Gated Endpoint Within Budget
The individual constraints are easy to satisfy in isolation and easy to breach in combination. Consider a realistic perimeter handler: it must reject unauthenticated traffic, serve a cached response when one exists, fall back to origin otherwise, and record a sampled trace — all inside Vercel’s 1000 ms wall-clock window and Cloudflare’s synchronous CPU budget.
const PUBLIC_PATHS = new Set(['/health', '/robots.txt']);
export async function gatedHandler(req: Request, ctx: ExecutionContext) {
const url = new URL(req.url);
// 1. Cheapest check first: a Set lookup costs microseconds and skips everything below.
if (PUBLIC_PATHS.has(url.pathname)) {
return new Response('OK', { status: 200 });
}
// 2. Reject before spending CPU on signature verification.
const token = req.headers.get('authorization')?.slice(7);
if (!token) return new Response('Unauthorized', { status: 401 });
// 3. Cache lookup keyed on path + tenant, not on the raw token.
const claims = await verifyToken(token); // ~4-6 ms CPU, JWKS already warm
if (!claims) return new Response('Invalid or expired token', { status: 403 });
const cache = await caches.open('gated-v1');
const cacheKey = new Request(`${url.origin}${url.pathname}?t=${claims.tenant}`, {
method: 'GET',
});
const hit = await cache.match(cacheKey);
if (hit) {
ctx.waitUntil(recordTrace(req, ctx));
return hit;
}
// 4. Only now pay for the origin round-trip.
const upstream = await fetch(req, {
headers: { ...Object.fromEntries(req.headers), 'X-Tenant': claims.tenant },
});
const response = new Response(upstream.body, upstream);
response.headers.set('Cache-Control', 'private, max-age=60');
ctx.waitUntil(cache.put(cacheKey, response.clone()));
ctx.waitUntil(recordTrace(req, ctx));
return response;
}
Four properties make this fit. The ordering is cheapest-first: a Set membership test costs microseconds, a header read costs nothing, and signature verification — the only real CPU expense — runs after both. The cache key is derived from the tenant claim rather than the token, so a thousand distinct sessions for one tenant share a single cache entry instead of a thousand. The response body is streamed by passing upstream.body straight through, which means the isolate never buffers the payload into its 128 MB heap; a 40 MB download passes through as chunks. And both the cache write and the trace are deferred with ctx.waitUntil(), so neither appears in the latency the client observes.
The subtle bug in this shape is response.clone(). A Response body is a single-use stream; handing the same body to both the client and cache.put() throws TypeError: Body has already been used. Cloning forks the stream, but the clone forces the runtime to buffer whatever the slower consumer has not yet read — so on very large responses, cloning reintroduces the memory pressure that streaming avoided. For payloads above a few megabytes, cache at origin with Cache-Control headers instead of writing to the Cache API from the isolate.
Observability
Telemetry at the edge must be asynchronous and sampled. Synchronous logging blocks the main thread and consumes CPU budget.
const SAMPLE_RATE = 0.1;
export function recordTrace(req: Request, ctx: ExecutionContext) {
if (Math.random() > SAMPLE_RATE) return;
const payload = {
timestamp: Date.now(),
method: req.method,
path: new URL(req.url).pathname,
rayId: req.headers.get('cf-ray') ?? 'unknown',
};
ctx.waitUntil(
fetch(process.env.TELEMETRY_URL!, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(() => {})
);
}
Propagate traceparent and tracestate headers through all upstream fetch calls to maintain W3C Trace Context continuity. Never log request bodies or sensitive headers at the edge—log request IDs and route them to secure aggregation pipelines.
Edge Cases and Failure Modes
The limits in the table above are the ones documented on pricing pages. The ones that actually break deployments are quieter.
Subrequest ceilings. Cloudflare caps outbound fetch calls per request — 50 on the free plan, 1000 on paid — and cache misses count. A handler that maps over an array of identifiers and fetches each one works perfectly against ten test records and fails in production at the fifty-first with Too many subrequests. Batch the fan-out into a single upstream call, or move it to a regional service.
Bodies are single-use streams. Calling await req.json() and then passing the same req to fetch() throws, because the body stream is already drained. The fix is to read once and reconstruct: capture the text, then build a fresh Request with that string as the body. The same rule applies to responses, which is why clone() appears in the worked example above.
Buffering is how you hit the memory cap. 128 MB sounds generous until an await res.arrayBuffer() on a large upstream response materializes the whole payload in the heap alongside the compiled script and every live object. Streaming through res.body keeps peak memory at chunk size regardless of payload; buffering makes peak memory equal to payload size. Nearly every edge out-of-memory incident traces to one arrayBuffer(), text(), or json() call on something unbounded.
Timers do not measure CPU. On Cloudflare, Date.now() advances only across I/O boundaries — a deliberate mitigation against timing side channels. Two Date.now() calls bracketing a pure-computation block return the same value, so a self-built CPU profiler reports zero. Measure with the platform’s own CPU metrics rather than instrumenting inside the isolate.
Regular expressions have no yield point. Catastrophic backtracking on a user-supplied string is the fastest route to the CPU wall, and there is no preemption to save you: the isolate is terminated mid-execution and the client receives a 502 with no application-level log entry. Anchor patterns, avoid nested quantifiers over untrusted input, and cap input length before matching.
waitUntil() is best-effort. Work scheduled after the response still runs inside the same isolate against the same limits, and if it exceeds them it is cancelled without an error surface. It is correct for logging and cache warming, and wrong for anything whose loss would be a correctness bug — a billing event or an audit record belongs in a durable queue, not a deferred promise.
| Failure signal | Typical cause | First move |
|---|---|---|
502 with no application log |
Isolate terminated mid-execution (CPU or memory) | Cap input sizes; stream instead of buffering |
504 after a fixed interval |
Wall-clock timeout on an unbounded upstream call | Wrap outbound fetch in an AbortController |
Too many subrequests |
Per-request fetch fan-out exceeded |
Batch upstream calls; move fan-out off the edge |
| Sporadic wrong-user data | Request-derived value stored at module scope | Move state into request scope or Durable Objects |
| Deploy succeeds, first hit is slow globally | Expensive top-level evaluation, re-run per PoP | Defer to a lazy singleton; shrink the bundle |
Deployment Checklist
- Type check & lint:
tsc --noEmitwith edge-specific ESLint rules (flagprocess,fs,net). - Build & bundle:
esbuild --bundle --minify --target=es2022 --format=esm --external:node:* - Validate constraints: bundle size < provider limit (1 MB uncompressed for Cloudflare Workers), no synchronous I/O, WebCrypto only.
- Inject secrets: map environment variables to platform secret stores; never hardcode.
- Deploy & verify: health-check across 3+ PoPs.
- Monitor & roll back: alert on p95 latency > 200 ms or error rate > 1%; automate rollback.
For initialization latency patterns see Managing Cold Starts in Serverless Environments. For bridging Node.js dependencies see Polyfill Strategies for Node.js APIs at the Edge. For bundle sizing see Edge Bundle Optimization Techniques.
Conclusion
Edge runtimes are not general-purpose compute environments. They are routing, transformation, and cryptographic verification layers that operate under hard constraints. Every architectural decision—algorithm selection, dependency choice, caching strategy—must be evaluated against the memory ceiling and CPU budget of the target provider. Design for the constraint, not around it.
Frequently Asked Questions
What is the difference between an edge runtime and a serverless function?
An edge runtime executes JavaScript in a pre-warmed V8 isolate at a Point of Presence near the user, exposing only Web APIs with hard memory (128 MB) and CPU limits. A serverless function runs in a full Node.js, Python, or Go container in a single region, with OS access, larger payloads, and longer timeouts but a measurable cold start. Edge suits stateless routing, auth, and transformation; serverless suits heavy compute and direct database connections. See when to use edge versus serverless functions for API calls.
Why are Node.js modules like fs and crypto unavailable at the edge?
Edge isolates run a curated WHATWG-compliant runtime, not a Node.js process, so OS-level modules such as fs, net, tls, and the Node crypto module have no underlying system to bind to. Use crypto.subtle for cryptography, fetch for network I/O, and edge storage (KV, R2, Blobs) instead of a filesystem. For bridging dependencies that still expect Node built-ins, see the polyfill strategies guide.
How much memory and CPU do edge runtimes give me?
Cloudflare Workers and Vercel Edge cap memory at 128 MB per isolate; Netlify Edge Functions allow 512 MB. Cloudflare enforces a synchronous CPU budget (10 ms free, up to 30 s on paid plans) separate from wall-clock time, while Vercel and Netlify apply only a wall-clock limit. Full per-provider figures are in memory and CPU limits across edge providers.
Do edge functions have cold starts?
V8 isolates initialize in under 1 ms when warm because the engine is already running and the script is snapshotted, so edge cold starts are far smaller than container cold starts. They are not zero: a brand-new script, a large bundle, or expensive top-level initialization still adds latency. Keep top-level work minimal and bundles small; the cold-start management guide covers the measurement and mitigation workflow.
What is the maximum bundle size for an edge function?
Cloudflare Workers cap a script at 1 MB uncompressed on the free plan and up to 10 MB gzipped on paid plans; Vercel Edge allows roughly 1–4 MB compressed; Netlify Edge Functions allow 20 MB. Tree-shaking, ESM-only imports, and replacing Node built-ins shrink bundles below these caps — see edge bundle optimization techniques.
Can I share state between requests in an edge runtime?
Only values derived from code and environment. A warm isolate reuses its module scope across every request it serves, so compiled patterns, lookup tables, and a JWKS cache are safe to hold there. Anything derived from a request is not: a module-level variable holding a user id becomes a cross-request data leak under concurrency, and because requests are not sticky to any one isolate, a module-level counter resets unpredictably. Use Durable Objects for state that must be shared and correct.
What happens when an edge function exceeds its CPU limit mid-request?
The isolate is terminated immediately at the JavaScript engine level — there is no preemption, no unwinding, and usually no application-level log line. The client receives a 502, and work scheduled with ctx.waitUntil() is discarded. Because termination is abrupt, the usual culprits are unbounded synchronous work: catastrophic regex backtracking, parsing a large JSON payload, or a loop over an unbounded collection. Cap input length before processing and move heavy computation off the edge.
How many outbound fetch calls can one edge request make?
Cloudflare Workers allow 50 subrequests per request on the free plan and 1000 on paid plans; cache misses count toward the total. Vercel and Netlify do not publish a hard subrequest count but bound you in practice through the wall-clock limit, since sequential calls accumulate elapsed time. Fan-out loops are the usual way teams discover this: batch upstream calls into one request, or defer the fan-out to a regional service.
Why does reading the request body break my proxy handler?
A Request body is a single-use stream. Once await req.json() or await req.text() has drained it, passing the same req to fetch() throws. Read the body once into a variable, then construct a fresh Request with that value as the body when forwarding upstream. The same constraint applies to responses, which is why a response must be cloned before it is both returned to the client and written to the Cache API.
Related
- Supported Web APIs in edge runtimes
- Vercel Edge Runtime vs Cloudflare Workers
- Managing cold starts in serverless environments
- Memory and CPU limits across edge providers
- Polyfill strategies for Node.js APIs at the edge
- Edge bundle optimization techniques
- Middleware chain architecture and request flow
- Edge caching and CDN integration