Last Black Friday, one of our edge image optimization functions started getting killed at 02:14 AM with no stack trace, no error log, just a cold "exceeded memory" in the dashboard. We'd tested it at 400 RPS in staging and it was fine. In production, with real user JPEGs and those chunky 8MB iPhone HEICs hitting the same PoP, we blew past the 128MB limit in under 12ms. I've seen that pattern repeat for a decade — what works on your laptop doesn't survive contact with CDN scale.
Why 128MB Isn't Really 128MB at the Edge
When vendors say you get 128MB, they don't mean 128MB for your JavaScript heap. That number has to cover everything the isolate or microVM needs to run your code. On V8 isolate-based platforms like Cloudflare and Fastly, that includes the compiled code itself, the event loop bookkeeping, any Wasm modules you've instantiated, and the overhead for handling the request and response objects. I learned this the hard way back in 2019 when we bundled a 4MB regex-heavy routing library that compiled to nearly 18MB in memory — we hadn't even allocated a single user object and we were already 14% gone.
The other gotcha is how memory is accounted for under concurrency. Most edge platforms don't give you 128MB per request; they give you 128MB per isolate that might be handling dozens of concurrent requests. If you're caching promises or keeping a shared Map for deduplication — and you should be — that memory is shared and it fragments fast. Unlike in a traditional Node server where GC can run lazily, edge runtimes are aggressive. They'll terminate your function mid-execution if you spike, because that isolate is sitting on a box with 2000 others. There's no swap, no grace period. This is where understanding Edge Function Execution Limits: CPU Time, Subrequests, and Creative Workarounds becomes critical, since memory pressure and CPU time often collapse together.
// Don't trust .length - measure actual isolate pressure
// Works in Cloudflare Workers, Vercel Edge, and other V8 isolate runtimes
function getMemoryPressure() {
// performance.memory is non-standard but available in many edge isolates
if (performance.memory) {
return {
usedJSHeap: Math.round(performance.memory.usedJSHeapSize / 1024 / 1024),
totalJSHeap: Math.round(performance.memory.totalJSHeapSize / 1024 / 1024),
limit: Math.round(performance.memory.jsHeapSizeLimit / 1024 / 1024)
};
}
return null;
}
export default {
async fetch(request, env) {
const before = getMemoryPressure();
// Your logic here - keep allocations scoped
const result = await handleRequest(request);
const after = getMemoryPressure();
if (after && before) {
const delta = after.usedJSHeap - before.usedJSHeap;
// Log to analytics, not console - console buffers cost memory too
if (delta > 10) {
// Flag requests that leak >10MB per invocation
env.METRICS.writeDataPoint({
blobs: [request.url, `mem_delta:${delta}`],
doubles: [delta]
});
}
}
return result;
}
}
Taming Allocations: Streams, Buffers, and the GC You Can't Control
You can't tune the garbage collector at the edge. There's no --max-old-space-size flag, no way to force a major GC between requests. What you *can* do is stop creating garbage in the first place. The single biggest win I've seen teams miss is buffering entire responses. I audited a personalization function last year that did `await response.text()` on a 2.5MB HTML document, then ran three regex replacements, then created a new Response. For a few milliseconds, that function held three copies of the same document in memory — the original buffer, the string, and the new response body. At 50 concurrent requests, that's 375MB fighting for a 128MB box. It never had a chance.
The fix is almost always streaming and zero-copy where the platform allows it. Instead of materializing bodies, transform them as they flow through. Use `TextDecoderStream` and `HTMLRewriter` patterns rather than string concatenation. Reuse `ArrayBuffer` instances for binary work, and be ruthless about dropping references — null out large objects as soon as you're done, don't wait for them to go out of scope. And watch your dependencies. A single import of a date-fns or lodash bundle can pull 600KB of code that stays resident forever. I've started using bundle analyzers that report *isolate heap impact*, not just file size, because that's the number that matters. For deeper visibility, pair this with Edge Function Debugging Techniques: Console, Tail Workers, and Remote Inspection — you can't fix what you can't measure, and `console.log` itself allocates.
// Anti-pattern: buffering (will OOM under load)
// const html = await upstreamResponse.text();
// const modified = html.replace('{{USER}}', name);
// return new Response(modified, upstreamResponse);
// Preferred: streaming transform with fixed memory ceiling
export async function handleRequest(request) {
const upstream = await fetch(request);
// HTMLRewriter and TransformStream keep memory constant
// regardless of response size - ~2-3MB steady state vs 15MB+ buffered
const rewriter = new HTMLRewriter()
.on('[data-personalize]', {
element(el) {
// Runs per-element, no full DOM in memory
const userId = request.headers.get('x-user-id') || 'guest';
el.setAttribute('data-user', userId);
}
});
// For non-HTML, use a TransformStream to avoid concatenation
const { readable, writable } = new TransformStream({
transform(chunk, controller) {
// Process chunk-by-chunk - chunk is typically 16KB-64KB
controller.enqueue(chunk);
}
});
// Pipe without ever holding full body
if (upstream.headers.get('content-type')?.includes('text/html')) {
return rewriter.transform(upstream);
}
upstream.body.pipeTo(writable).catch(() => {});
return new Response(readable, upstream);
}
Real-World Memory Budgets by Platform
Every provider documents a limit, but the *enforced* limit and the *usable* limit are two different numbers. After running load tests across the big four for a client migration, I've found you really want to stay under 70-75% of the documented max to survive traffic spikes and PoP-level noisy neighbors. The table below reflects what we measured in late 2023-2024 with wrangler, fastly compute, and Lambda@Edge test harnesses — not just what the docs claim. Keep in mind that cold starts also eat into this budget; a heavy initializer that allocates 30MB before handling the first byte leaves you very little headroom. That's why this pairs so tightly with Edge Function Cold Start Optimization: Reducing Initialization Latency to Milliseconds.
When you're designing for these limits, follow the guidance in the HTTP Working Group Specifications for handling headers and bodies efficiently — especially around content-length and transfer-encoding — and the patterns in web.dev Articles on streaming and ArrayBuffer reuse. Both resources have saved us from re-implementing broken buffering logic. A good rule I've used since 2016 is: if your function needs more than 50ms to parse a body, you should be streaming it, not reading it.
One last thing teams forget: global scope is memory that never gets freed. Anything you assign outside your fetch handler — clients, compiled schemas, regexes, even that handy `const cache = new Map()` — lives for the lifetime of the isolate, which could be hundreds of requests. We had a GraphQL validation function that compiled its schema on first request and cached the AST globally. Brilliant for CPU, terrible for memory, because each schema version leaked and was never evicted. We fixed it by adding an LRU with a hard byte limit instead of an unbounded Map.
| Platform | Documented Limit | Usable Budget (Target) | Enforcement | Overage Behavior |
|---|---|---|---|---|
| Cloudflare Workers | 128 MB per isolate | 85-90 MB | Hard wall | Immediate termination, no catch, 1101 error |
| Fastly Compute@Edge | ~150 MB (Wasm linear memory) | 100-110 MB | Hard wall | Wasm trap, request fails, isolate recycled |
| AWS Lambda@Edge | 128-512 MB configurable | 70% of configured | Soft + hard | Throttle then kill, logged to CloudWatch with delay |
| Vercel Edge Functions | 128 MB per function | 80-85 MB | Hard wall | 500 + MEMORY_EXCEEDED, often without stack |
Putting It Into Practice: A Memory-Safe Request Handler
Knowing your budget is one thing — staying inside it under real traffic is another. The pattern that’s saved us the most headaches at the edge is simple: never buffer what you can stream, and never clone what you can reuse. In our production edge handlers, we’ll aim to keep the steady-state working set under 35-45MB even on a 128MB Worker, because that leaves ~60MB of headroom for a sudden 10MB JSON payload or an image transform without tripping the isolate’s OOM killer.
The biggest win comes from treating the fetch event as a pipeline, not a bucket. If you’re proxying an API response or injecting headers, don’t do await response.text() or await response.json() and then rebuild it. That doubles your allocation instantly. We've measured a 4.2MB JSON response ballooning to 18-22MB in heap after parsing and stringifying. Instead, pipe through a TransformStream and work in fixed 16KB-64KB chunks. You’ll also want to move any large regexes, compiled route tables, or WASM modules to the global scope so they’re instantiated once per isolate, not per request — but watch that global footprint like a hawk. A 12MB GeoIP MMDB left in global scope looks cheap until 20 isolates spin up on the same PoP.
Here’s a stripped-down handler we use for HTML streaming rewrites that stays under ~8MB even on 2MB documents. Notice we cap buffer size and avoid ever holding the full body in memory:
const REWRITE_CHUNK_SIZE = 16384; // 16KB - sweet spot for V8 GC
const encoder = new TextEncoder();
const decoder = new TextDecoder();
export default {
async fetch(request, env, ctx) {
const upstream = await fetch(request);
if (!upstream.body || upstream.headers.get('content-type')?.includes('image')) {
return upstream; // Don't touch binary - pass through
}
let buffer = '';
const transform = new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
// Process in bounded chunks, don't accumulate
while (buffer.length >= REWRITE_CHUNK_SIZE) {
let slice = buffer.slice(0, REWRITE_CHUNK_SIZE);
buffer = buffer.slice(REWRITE_CHUNK_SIZE);
// Example: inject nonce, strip comments - keep it cheap
slice = slice.replace('<head>', '<head><meta name="x-edge" content="1">');
controller.enqueue(encoder.encode(slice));
}
},
flush(controller) {
if (buffer) {
controller.enqueue(encoder.encode(buffer));
buffer = ''; // Explicitly release
}
}
});
return new Response(upstream.body.pipeThrough(transform), upstream);
}
}
Advanced Patterns and Debugging OOMs at the Edge
Once you’ve got streaming down, the next memory killers are isolate reuse and cold-start bloat. You don’t control the GC — you can’t call global.gc() on Cloudflare or Vercel — so you have to write GC-friendly code. That means avoiding closures that capture large objects, nulling references after use, and pooling Uint8Array buffers instead of allocating fresh ones per request. We’ve seen a Fastly Compute service drop p99 latency from 28ms to 11ms and cut OOMs by 90% just by reusing a single 32KB buffer pool instead of allocating a new buffer for every image header parse.
When you do OOM, the error is maddeningly vague: Exceeded memory limit or just a silent 502. Your best friend is local memory profiling *before* deploy. Don’t trust process.memoryUsage() alone. Use --inspect with Chrome DevTools on a miniflare/Wrangler local run, or compute-js-build --profile for Fastly, and look at the heap snapshot after 1,000 simulated requests. If your heap grows 200KB per request, you’ve got a leak — usually a Map/Set or global array that never gets pruned. For a full pre-prod check, we bake memory assertions into our pipeline as described in Edge Function Testing Strategies: Unit, Integration, and End-to-End at the Edge. It catches the classic bug where a logging library buffers 5,000 request logs in memory.
For rollout, don’t ship a memory-heavy change to 100% of traffic. That’s how you take down a region. Use a controlled deployment — see Edge Function Versioning and Deployment Patterns: Blue-Green, Canary, and Gradual — and watch the platform’s isolate memory metrics (Cloudflare Workers Analytics, Fastly Stats) during a 1% canary for at least 15 minutes. The same progressive approach applies if you’re using edge logic for A/B Routing with Edge Functions: Traffic Splitting Without Origin Changes; a leaky B-variant will only poison isolates serving that bucket, which makes it look like intermittent failures if you aren’t segmenting metrics by variant.
Frequently Asked Questions
Why does my function OOM at 70-80MB when the limit is 128MB?
Because 128MB is the isolate hard limit, not your heap limit. The runtime itself, V8 internals, code segment, and per-request overhead eat 30-45MB before your code runs. On Cloudflare, we consistently see OOMs at ~75MB of user heap. On Vercel Edge (also 128MB), it’s even tighter — closer to 68MB usable. Always budget for 50-60% of the advertised limit as actual heap.
How do I handle image optimization or PDF generation at the edge without blowing memory?
You probably shouldn’t do the heavy lifting in the edge function itself. Stream the asset to a dedicated image service or use the platform’s native primitives (like Cloudflare Image Resizing or Fastly Image Optimizer) which run outside your isolate’s memory. If you must transform, never load the whole file — use streaming decoders and limit input to e.g., 8MB max, returning a 413 otherwise. We’ve seen a single 12MP JPEG decode spike heap by 85MB.
Can I just increase memory or add `--max-old-space-size`?
No. At the edge you can’t tune V8 flags. Limits are per-platform and per-plan: Cloudflare Workers paid is still 128MB per isolate, Fastly Compute is 64MB to 128MB depending on package, Vercel Edge is 128MB. The only “increase” is moving to a different compute product — like Cloudflare Workers with Workers Containers or Fastly’s heavier compute — but that’s not edge-light anymore and adds 80-150ms cold start.
Does global scope caching help or hurt memory?
Both. It helps latency and avoids per-request allocation, but it’s the #1 cause of slow memory leaks. An object cached globally persists for the life of the isolate, which can be 100s to 10,000s of requests. If you cache fetch responses, parsed configs, or WASM instances globally, implement a strict LRU with a byte-size cap (e.g., 10MB max) and a TTL. And never cache per-request data like `request.headers` globally.