"Worker exceeded memory limit": 18 days of fixing it on Cloudflare Workers

Engineering8 min read

Your coding agent shows "API Error", and you did nothing wrong. For 18 days, that is what some Context Mode users saw. Our gateway runs on Cloudflare Workers, and long agent sessions send 20 to 36 MB on every turn. When one of those requests pushed an isolate past 128 MB, Cloudflare killed the isolate, and every request on it failed with it. Here is what caused it, what did not work, what we moved to Rust, and the checklist we would give anyone with heavy request bodies on Workers. Killed requests went from 35.2 per hour to 0.

The short version

The problem

A coding agent sends the whole conversation on every turn. A long session reaches 2,000 to 7,000 messages and 20 to 36 MB per request. Every request on one isolate shares the same 128 MB. So one big request can kill every small request next to it. On 22 October, one isolate death took 64 requests with it.

Day (UTC)Killed requestsIsolate deaths
October 1721243
October 1814843
October 193311
October 2028261
October 2124855
October 221,054101
October 23, to 09:4031

We count two numbers. A death is one isolate killed. A killed request is every request that was on it. One number hides the other.

See it in one isolate

Pick a case

A long session sends 36 MB. The heap that stays is small. What it makes on the way is not.

Live heap after GC38 MB
Decode the body to text144 MB
Count its bytes101 MB
One toLowerCase51 MB

Drawn to scale. The red line is the 128 MB limit of one isolate.

Every request on the isolate shares the same 128 MB. GC cannot free garbage as fast as this turn makes it, so the isolate dies, and every request on it fails.

We kept the last 64 characters of each body in a map.

// a view: keeps the whole body alive
tail = raw.slice(N - 64, N)

// a new string: the body can go
tail = copy(raw, N - 64, N)
Kept after 80 chats, slice57 MB
Kept after 80 chats, copy2.4 MB
Peak, slice98 MB
Peak, copy36 MB

Drawn to scale. The red line is the 128 MB limit of one isolate.

128 entries per isolate. Normal 1 MB requests then killed isolates.

Big bodies wait for a memory budget. A timer let them in anyway.

24.9 MB body24.9 MB
24.9 MB body24.9 MB
9.0 MB body9 MB
7.2 MB body7.2 MB
Held at death, bodies only66 MB

Drawn to scale. The red line is the 128 MB limit of one isolate.

One isolate died holding all four. Without a bound, one death took 64 waiting requests. Now: no timer admits over the budget, at most 4 large bodies wait, the rest get a 408 the client retries.

The same 35.5 MiB body, 4 requests at once.

JavaScript, heap111.4 MB
Rust and Wasm, heap and Wasm25.3 MB

Drawn to scale. The red line is the 128 MB limit of one isolate.

CPU per request  471.0 → 211.0 ms −55.2%
Wasm memory      2.9 MB, 10 bodies

The body never goes into Wasm whole. It passes through one 1 MiB buffer, in slices.

Six platform facts that decided everything

  1. 128 MB per isolate, shared by every request on it.
  2. Garbage counts until GC runs. On a 36 MB request, the live heap after a forced GC was about 38 MB. But the turn allocated about 144 MB to decode the body to text, 101 MB to count bytes and 51 MB in one toLowerCase.
  3. A V8 slice keeps its parent alive. raw.slice(N-64, N) is a 64-character view of the whole string.
  4. Wasm memory never shrinks. Once an instance grows, it keeps that memory for the life of the isolate. Copy a 30 MB body into it once, and you lose 30 MB for good.
  5. Isolate memory is not shared. A map or cache in one isolate is empty in the next. The Cache API is per data center and outlives isolates.
  6. waitUntil keeps its inputs alive. After the answer, background writes still held 26.7 MB on a 36 MB turn. It was gone 300 to 500 ms later: queued work, not a leak.

Two more limits came from outside. The model provider takes at most 32 MB per request, so the gateway must read the body to cut it. And Workers run on V8, so Bun is not an option. The only native-speed path inside a Worker is Wasm.

What did not work

We triedWhy it failed
Move the console UI to its own WorkerNot needed. The UI was 3.95 MiB of heap; a lazy import saved 3.78 MiB. The deaths came from bodies, not code size.
Lazy decode in the JSON scannerEvery consumer decoded each element anyway. The cost moved; it did not go away.
A hand-written JSON writerWorse: peak +8.6 MB, CPU +21%.
Roll back to the last versionThe old version also died, 7.2 times per hour at afternoon load. The cause was older than the new commits.
An unbounded wait queueDeaths got bigger. One death took every queued request: 64 at once.
SHA-256 inside WasmIt ate the gain: only 12 to 16% less CPU. Native SHA-256 in JavaScript fixed it.

The causes we found and fixed

Local numbers come from a heap probe with bodies shaped like real traffic. Every fix went straight to production.

1. The archive wrote the whole conversation again on every turn

"This message is already stored" lived only in isolate memory. A new isolate archived all 7,700 messages again. Now a record per conversation sits in the Cache API, written only after the store confirms. On a 24 MB conversation in a new isolate: CPU 2.7 s to 0.74 s, peak 92 to 51 MB, lookups 7,746 to 16.

2. A tokenizer built every word one character at a time

cur += fold(c) for each character. One 36 MB turn allocated 1,473 MB in the archive write. Slicing runs of characters that need no change cut it to 141 MB.

3. A timer let bodies in over the budget

Big bodies wait for a memory budget. But after a timeout (2 s to 240 s), a waiting body got in anyway. One isolate died holding two 24.9 MB bodies and two more of 9.0 and 7.2 MB. Now no timer admits over the budget.

4. The queue had no bound

After fix 3, waiting bodies stayed on one isolate for hours, and one death took them all. Now an isolate holds at most 4 waiting large bodies. The rest get a 408 with retry-after and x-should-retry: true. We first used 503, but agent clients treat some overload answers as a reason to switch models. We checked the real client code and tested that it retries the 408.

5. A 64-character tail kept the whole body alive

We stored the last 64 characters of each body in a map, with slice. V8 kept each full body alive, 128 per isolate. Normal 1 MB requests then killed isolates. The fix is to copy the tail into a new string. Over 80 conversations in a row: retained memory 57 to 2.4 MB, peak 98 to 36 MB.

6. Large bodies were parsed whole

Bodies over 12 MiB are now read item by item from the Rust scanner, never as one JSON.parse. Peak for a 30 MiB body: 95 to 32 MB.

The move to Rust and Wasm

The rule: everything that does memory or CPU work on a request body moves to Rust. JavaScript keeps only I/O: the Worker entry, bindings, fetch, storage calls and waitUntil. We built a prototype first, on workerd with a 35.5 MiB body, 3 runs:

JavaScriptRust and WasmChange
CPU per request, 2 at once486.7 ms205.0 ms−57.9%
CPU per request, 4 at once471.0 ms211.0 ms−55.2%
Heap and Wasm, 2 at once49.7 MB23.9 MB−51.8%
Heap and Wasm, 4 at once111.4 MB25.3 MB−77.3%

Forwarded bytes and archive ids matched in 51 of 51 requests. Wasm memory stayed at 2.9 MB over 10 bodies in a row. Rust now does the JSON scan, the stream cut, hashes, archive records, length counts and the secret check, with one shared Wasm instance per isolate. Workers Builds compiles it from source; we commit no binary.

Some JavaScript stays, and we say where: JSON.parse on bodies of 1 to 12 MiB, and one parse per message in Recall. Moving them means the passes must edit bytes, not objects.

Live results

Before35.2
Bounded queue, 408 retry3.4
Tail copy, Rust secret gate0
Killed requests per hour, from Workers Observability. The last bar is October 23, 05:31 to 09:31 UTC.
Window (UTC)Killed per hourDeaths per hourCPU p99CPU max
Oct 22, 17:19 to 21:19, before35.24.0977 ms11,314 ms
Oct 22, 21:20 to 23:07, bounded queue3.41.1456 ms1,570 ms
Oct 23, 03:27 to 08:500.60.2590 ms1,415 ms
Oct 23, 05:31 to 09:3100574 ms1,041 ms

Night has less traffic than day, so compare windows at the same time of day. One death at 04:32 (one request) we could not reproduce yet.

What we got wrong

A checklist for heavy request bodies on Workers

  1. Count isolate deaths and killed requests, per version.
  2. Watch allocation, not only retained heap. Profile a body shaped like your real traffic.
  3. Never keep a slice of a big string in a long-lived map. Copy it.
  4. Keep "already done" records in the Cache API, written after the store confirms. Isolate memory is gone on the next isolate.
  5. Admit big bodies against a byte budget. Never let a timer admit over it. Bound the queue, and answer the rest with a status your clients really retry.
  6. Do not parse a 20 MB JSON body into objects if you can scan bytes.
  7. In Wasm: one reusable buffer, never the whole body, hashes native in JavaScript, and a test against the old output for every part.
  8. Make your heap tests load the same module production loads.
  9. Decide on 3 hours or more of daytime traffic, not the first quiet hour.

Get started free

Your coding agent costs less and remembers your project.

Start free: 1,000 requests, every feature, no card. After you sign in, the console gives you one command to connect Claude Code or Codex.

Read the docs