Memory and CPU Limits Across Edge Providers

Edge runtimes impose hard resource boundaries enforced at the JavaScript engine level, not the OS level. Exceeding memory causes an immediate OOM kill. Exceeding the CPU budget (where one exists) triggers a hard timeout error. Neither condition produces a graceful error page—the platform returns a 502 or 503 to the client. Understanding exactly where each provider draws those lines is a prerequisite for capacity planning.

This guide is part of Edge Runtime Fundamentals & Platform Constraints. It maps the two ceilings every edge workload must respect — resident memory and synchronous CPU time — across all three major providers.

Memory and CPU ceilings at the edge Resident memory rising into the cap triggers an OOM kill, while synchronous CPU time crossing the budget triggers a timeout error; both return 5xx to the client. Memory ceiling 128 MB cap → OOM kill (502/504) resident memory over time CPU budget 10 ms (CF free) → timeout (1101) sync CPU I/O wait free over budget
Memory and synchronous CPU are independent ceilings: OOM ends the isolate, CPU overrun times out, and outbound I/O wait does not consume the CPU budget.

Provider Limits

Cloudflare Workers

  • Memory: 128 MB per isolate (hard cap)
  • CPU: 10 ms synchronous CPU per request on the free tier; 30 s by default on the Workers Paid plan, configurable up to 5 minutes. I/O wait (outbound fetch, KV reads, DO calls) does not consume this budget.
  • Wall-clock: 30 s
  • Bundle size: 1 MB uncompressed script

The CPU budget is the defining constraint. A synchronous SHA-256 hash of a 10 MB buffer, or a regex evaluated against a large string, can exhaust 10 ms without any network I/O. Exceeding the CPU quota returns a Worker threw exception error (Cloudflare error code 1101). For the diagnostic and remediation workflow specific to this failure, see avoiding CPU time-limit errors in Cloudflare Workers.

Vercel Edge Middleware

  • Memory: 128 MB per invocation (hard cap)
  • CPU: No separate CPU time budget; wall-clock limit applies
  • Wall-clock: 1000 ms for middleware.ts
  • Bundle size: 1 MB uncompressed

Vercel does not enforce a separate synchronous CPU limit. The 1000 ms wall-clock includes all computation and I/O. This is more permissive than Cloudflare for CPU-bound operations but less permissive for long-running I/O chains.

Netlify Edge Functions

  • Memory: 512 MB per invocation
  • CPU: No separate CPU budget
  • Wall-clock: 50 s
  • Bundle size: 20 MB

Netlify’s Deno-based runtime is the most permissive in terms of memory and execution time, making it a reasonable choice for transformation-heavy middleware that cannot be offloaded to origin. For a focused breakdown of the 128 MB versus 512 MB difference and the distinct OOM signatures, see comparing memory limits: Netlify vs Vercel Edge.

Summary Table

Provider Memory CPU budget Wall-clock Bundle
Cloudflare Workers 128 MB 10 ms (free) / 30 s default, up to 5 min (paid) 30 s 1 MB
Vercel Edge Middleware 128 MB 1000 ms 1 MB
Netlify Edge Functions 512 MB 50 s 20 MB

Reading the Failure Signature

The three ceilings fail in visibly different ways, and telling them apart from production evidence is usually faster than reproducing the fault. A memory overrun is the quietest: the isolate is terminated by the engine, so your catch blocks never run, no application log line is written, and the client receives a 502 or 504 with an empty body. If headers were already flushed because you were streaming, the client instead sees a truncated response with a 200 status — the most misleading signature of the three, because monitoring that only samples status codes will report success.

A CPU overrun is the loudest. Cloudflare terminates the request and surfaces error 1101 with a Worker threw exception entry, which does reach the log stream. A wall-clock overrun sits between the two: the platform aborts the invocation once elapsed time crosses the limit and reports it as an invocation timeout, without attributing it to any particular line of your code. The practical rule is that silence points at memory, an exception points at CPU, and a timeout points at an unbounded wait on something upstream.

Three ceilings, three signatures A memory overrun kills the isolate silently and returns an empty 5xx, while a CPU overrun raises a logged exception and a wall-clock overrun reports an invocation timeout. The evidence available in logs identifies which ceiling was hit. Which ceiling did this request hit? Ceiling Trigger Client sees Logs show First move Memory cap 128 MB, 512 on Netlify resident heap reaches the hard cap 502 / 504, empty body or a truncated 200 nothing — isolate killed stream, and gate on content-length CPU budget Cloudflare only synchronous compute exceeds the budget error 1101 page Worker threw exception chunk the work or offload the compute Wall-clock 1000 ms Vercel, 50 s Netlify elapsed time crosses the invocation limit 504 timeout invocation timeout bound every upstream fetch with a timeout Silence points at memory, an exception points at CPU, a timeout points at an unbounded upstream wait.
The absence of a log line is itself diagnostic: only the memory ceiling terminates the isolate before your error handler can run.

How the Cap Is Actually Counted

The number in the table is not a per-request allowance. It is a ceiling on everything resident in the runtime instance at a given moment, and four contributions add up to it.

The first is the runtime and framework baseline. A bare Worker starts a few megabytes in; a framework adapter with a compiled route manifest and a rendering runtime can occupy 20–40 MB before your handler executes a line. Subtract that overhead from the cap before you plan any working set, which is why the practical safe line on a 128 MB platform is closer to 88 MB.

The second is allocation that lives outside the JavaScript heap. ArrayBuffer, typed arrays, and Blob bodies are counted against the same cap even though heap profilers report them separately, and await response.text() on a payload materializes it as UTF-16 — roughly two bytes per input byte — while base64 encoding costs an additional third on top of the source bytes. A 30 MB JSON document read as text and then parsed can easily be resident three times over in different representations at the moment JSON.parse returns.

The third, and the one most often missed, is concurrency. On Cloudflare, a single isolate serves many in-flight requests for the same script; on Netlify, a Deno worker is shared across concurrent invocations. Memory is therefore pooled, not per-request. A handler that peaks at 15 MB looks perfectly safe in isolation and OOMs at 128 MB the moment nine requests overlap. Capacity planning has to multiply peak working set by realistic concurrency, not by one.

The fourth is timing. Garbage collection is neither instantaneous nor guaranteed to run before the next allocation, so dropping a reference does not immediately return resident memory. A burst can exhaust the cap while the heap is technically reclaimable, which is exactly why OOM kills tend to appear at p99 under load rather than in steady-state testing.

Streaming to Avoid OOM

Buffering large payloads is the most common cause of OOM kills on 128 MB platforms. Use TransformStream to process data in chunks and flush immediately:

export async function edgeHandler(request: Request): Promise<Response> {
  const startTime = performance.now();

  const response = await fetch('https://api.origin/data');
  if (!response.ok || !response.body) {
    return new Response('Upstream failed', { status: 502 });
  }

  // Reject payloads that would exceed safe memory headroom
  const contentLength = response.headers.get('content-length');
  if (contentLength && parseInt(contentLength, 10) > 80 * 1024 * 1024) {
    return new Response('Payload exceeds edge memory budget', { status: 413 });
  }

  // Stream without buffering; enforce a wall-clock guard per chunk
  const transformStream = new TransformStream({
    transform(chunk, controller) {
      if (performance.now() - startTime > 900) {
        // Approaching wall-clock limit; abort the stream
        controller.error(new Error('Execution budget approaching limit'));
        return;
      }
      controller.enqueue(chunk);
    },
  });

  return new Response(response.body.pipeThrough(transformStream), {
    headers: response.headers,
  });
}

Two independent guards are doing the work here, and they defend different ceilings. The content-length check refuses oversized payloads before a single byte is allocated, which is the only reliable defence against the memory cap, because once the allocation is under way there is no error handler left to run. The elapsed-time check inside transform defends the wall-clock limit, converting a silent platform abort into an explicit stream error you can log and attribute. Neither guard requires the payload in heap: one reads a header, the other reads a clock.

Two guards on one streaming path A content-length gate rejects oversized bodies with a 413 before allocation begins, and an elapsed-time check inside the transform aborts the stream before the wall-clock limit is reached. Chunks flow to the client without the full payload ever being resident. Where each guard sits relative to allocation Guard 1 · memory Guard 2 · wall-clock origin body size unknown content-length gate over 80 MB is refused TransformStream one chunk in, one out Response pipeThrough client 413 returned before any allocation controller.error() at 900 ms elapsed too large out of time peak heap ≈ one chunk, not one payload A header read and a clock read replace the buffered copy that would have triggered the OOM.
Both guards run before allocation grows: once the isolate is over the cap there is no handler left to return a friendly error.

Lazy Module Loading

Static top-level imports parse and compile at isolate initialization time. Deferred import() avoids this overhead for code paths that are rarely hit:

type HeavyProcessor = { process: (data: ArrayBuffer) => Promise<Uint8Array> };

let processorInstance: HeavyProcessor | null = null;
let initPromise: Promise<HeavyProcessor> | null = null;

export async function getProcessor(): Promise<HeavyProcessor> {
  if (processorInstance) return processorInstance;

  if (!initPromise) {
    initPromise = (async () => {
      try {
        const { HeavyProcessor } = await import('./heavy-processor');
        processorInstance = new HeavyProcessor();
        return processorInstance;
      } catch (error) {
        initPromise = null; // Allow retry on transient failures
        throw error;
      }
    })();
  }

  return initPromise;
}

For the relationship between module size and cold-start latency, see Managing Cold Starts in Serverless Environments.

A Worked Example: The Aggregator That Only Fails at p99

An aggregation endpoint fans out to three upstream services, parses each JSON response, merges the results, and returns one document. It passed every functional test, survived a single-request load check, and then returned intermittent 502s with empty bodies in production — only under traffic, and never reproducibly.

The arithmetic explains it. The three responses were 6 MB, 9 MB, and 4 MB of JSON, fetched concurrently with Promise.all, so all 19 MB were resident at once. Read as text before parsing, that is roughly 38 MB in UTF-16. Parsed into objects, a JSON document typically expands to three to five times its serialized size, so the object graphs added another 60–95 MB. Peak resident for one request landed near 110 MB — under the 128 MB cap, which is exactly why the single-request test passed. Two overlapping requests on the same isolate doubled that, and the engine killed the isolate before any handler could log a thing.

Four changes brought it back under control. Sending a HEAD request first and rejecting any upstream declaring more than 8 MB removed the tail-risk payloads entirely. Replacing Promise.all with sequential fetch-parse-merge cycles meant only one upstream document was resident at a time, and letting each parsed object go out of scope before the next fetch gave the collector a chance to reclaim it. Dropping the intermediate .text() call and parsing directly from the response removed the UTF-16 duplicate. Finally, the merged output was serialized into a stream instead of being assembled as one string.

Peak resident per request fell from about 110 MB to about 9 MB, which leaves headroom for eight concurrent requests inside the 88 MB safe line. The CPU side of the change is worth noting too: JSON.parse on 19 MB of input is synchronous work in the region of 90 ms, far beyond Cloudflare’s 10 ms free-tier budget and comfortably inside the paid budget. The memory fix reduced that as a side effect, because the parser now handles a fraction of the bytes — but if the workload had genuinely needed all 19 MB parsed, the correct answer would have been to move the merge to a regional function rather than to buy a larger CPU budget.

Observability

Platform-native CPU time headers are not consistently available. Use performance.now() to instrument critical paths:

const start = performance.now();
const result = await heavyOperation();
const elapsed = performance.now() - start;

if (elapsed > 20) {
  console.warn(JSON.stringify({
    level: 'warn',
    message: 'Heavy operation approaching CPU budget',
    elapsedMs: elapsed.toFixed(2),
  }));
}

For local load testing that simulates production memory caps:

# Cap Node.js heap to simulate 128 MB edge limit
node --max-old-space-size=128 dist/server.js

Workload Decision Matrix

Workload Memory Profile Recommended Strategy
JWT validation, auth routing < 5 MB Deploy to edge; CPU budget not a concern
API aggregation / BFF patterns 20–50 MB Edge with streaming; cache aggressively
Large JSON transformation > 80 MB Offload to regional serverless (Node.js, Python)
Static asset manipulation < 15 MB Edge with CDN caching
Database query + render Variable Serverless or containerized; not edge

When average CPU time on Cloudflare exceeds 25 ms, or sustained memory usage exceeds 80 MB on any 128 MB platform, route to a regional serverless function. The hybrid model—edge for routing and stateless transforms, serverless for compute—preserves low latency for user-facing requests while handling heavy operations in an elastic environment.

Peak heap per workload against the cap Auth routing and asset manipulation sit far below the safe line, aggregation approaches it, and large JSON transformation or database rendering exceeds the cap. Bars are per request, so realistic concurrency multiplies each figure. Typical peak resident memory for one request 88 MB safe line 128 MB cap Auth routing / JWT under 5 MB — CPU budget is the only concern Static asset manipulation under 15 MB — safe with CDN caching in front API aggregation / BFF 20–50 MB — only with streaming and bounded fan-out Large JSON transformation 80 MB and up — offload to a regional function Database query + render unbounded by result-set size — not an edge workload 0 40 80 128 MB These are per-request figures: multiply by concurrency, because one isolate serves many requests at once.
The safe line sits at 88 MB rather than 128 MB because runtime and framework overhead consume 20–40 MB before your handler allocates anything.

Common Pitfalls

Symptom Cause Fix
502/504 with no error body on Vercel/CF OOM kill from buffering a large payload in heap Stream with TransformStream; reject oversized bodies via content-length
Cloudflare error 1101 under load Synchronous CPU (hash, regex, parse) exceeds the 10 ms free budget Offload compute or upgrade to a paid CPU tier; see the CPU time-limit guide
Works locally, OOMs in production vercel dev / netlify dev do not enforce memory caps Run under node --max-old-space-size=128 before deploying
Slow first request despite small handler Large static imports parsed at isolate init, inflating both heap and cold start Defer rarely-hit modules with dynamic import()
Intermittent Netlify timeouts under concurrency Shared Deno worker-pool pressure, not a single-function limit Cap per-request memory and avoid unbounded cache writes

Resource-Boundary Checklist

Validate each ceiling before promoting middleware to production:

1. Confirm the memory headroom

Subtract runtime and framework overhead (roughly 20–40 MB) from the cap and verify peak heap stays below it under load.

2. Stream anything large

Replace synchronous buffering with TransformStream, and reject payloads above a safe content-length threshold with a 413.

3. Budget synchronous CPU

On Cloudflare, keep per-request synchronous work under the active CPU budget; move hashing and heavy parsing off the hot path.

4. Defer cold modules

Load rarely-hit code with dynamic import() so it never inflates initialization heap or parse time.

Frequently Asked Questions

What happens when an edge function exceeds its memory limit?

The platform issues an immediate OOM kill at the JavaScript engine level and returns a 502 or 504 with no application error body. There is no graceful catch — the isolate is terminated mid-request, so the only defense is staying below the cap by streaming rather than buffering.

Does outbound fetch count against Cloudflare's CPU budget?

No. The CPU budget measures synchronous compute only. Time spent waiting on outbound fetch, KV reads, or Durable Object calls is I/O wait and does not consume the 10 ms free-tier or paid CPU budget. Only synchronous work like hashing or regex evaluation does.

Which provider has the most permissive limits?

Netlify Edge Functions, with 512 MB of memory and a 50 s wall-clock on the Deno runtime, are the most permissive. Cloudflare’s 10 ms free-tier CPU budget is the strictest. Vercel Edge sits between them with 128 MB and a 1000 ms wall-clock that bundles compute and I/O together.

How do I simulate the 128 MB cap locally?

Neither vercel dev nor netlify dev enforces memory limits, so run your build under node --max-old-space-size=128 dist/server.js and drive load against it. This surfaces handlers that approach the ceiling before they OOM in production.

When should I move a workload off the edge entirely?

Route to a regional serverless function when average Cloudflare CPU time exceeds 25 ms or sustained memory passes 80 MB on any 128 MB platform. Keep routing and stateless transforms at the edge and push heavy compute to an elastic environment.

Is the memory cap per request or shared across concurrent requests?

It is a ceiling on the runtime instance, not on a single request. One Cloudflare isolate serves many in-flight requests for the same script, and a Netlify Deno worker is shared across concurrent invocations, so peak working sets add up. A handler that peaks at 15 MB is safe alone and fatal at nine concurrent requests on a 128 MB platform. Plan capacity as peak working set multiplied by realistic concurrency.

Why does reading a response as text double my memory usage?

await response.text() decodes bytes into a UTF-16 string, which is roughly two bytes per input byte, and the original body may still be resident while the string is built. Parsing that string then expands it again into an object graph three to five times its serialized size. Parse straight from the response where the API allows it, and prefer streaming over materializing intermediate representations.

Does response.clone() or tee() cost extra memory?

Yes, and the cost is unbounded. Cloning a response or teeing a stream means the runtime must buffer whatever the slower consumer has not yet read. If one branch is consumed quickly and the other slowly, the buffer grows to the difference between them. When you need the same bytes twice, read once and reuse the result, or accept the buffering deliberately with a size gate in front of it.

Why do OOM kills only show up at p99 under load?

Because garbage collection is neither instant nor guaranteed to run before the next allocation. Dropping a reference does not immediately return resident memory, so a burst can exhaust the cap while the heap is technically reclaimable. Steady-state testing gives the collector time to keep up; a traffic spike does not, which is why the failure looks intermittent and refuses to reproduce locally.

Conclusion

Edge memory and CPU limits are not soft suggestions—they are hard enforcement points that kill your request. Cloudflare’s 10 ms free-tier CPU budget is among the strictest constraints in the industry; Netlify’s 512 MB memory and 50 s wall-clock are the most permissive. Design middleware that stays well below these ceilings: stream large payloads, defer heavy modules, and route CPU-intensive work to serverless functions. For platform-specific comparison of memory allocation strategies see Comparing Memory Limits: Netlify vs Vercel Edge.