# How We Fixed Our Cloudflare Durable Objects Memory Exceeded Errors

> Cloudflare Durable Objects run in V8 isolates with a hard 128 MB limit. Ours idled at ~140 MB and was reset ~300 times a day. 130 MB of zod schemas were built at module load, most of them by code that was never called. How we profiled the production bundle, the two fixes, and the numbers, 218 MB to 82 MB and resets to zero.

Published: August 26, 2026
Authors: Aleksandr Diamond and Boris Tane
Canonical: https://polylane.com/blog/how-we-fixed-our-cloudflare-durable-objects-memory-exceeded-errors/

Every agent thread in Polylane runs inside its own Cloudflare Durable Object, all of them instances of one class. In August 2026, the Durable Objects of our threads were reset about 300 times a day for exceeding the memory limit. We heard the same story from multiple teams running their agentic flows on Cloudflare Durable objects.

If you've hit `exceededMemory` on a Durable Object, the usual advice is to look at what your requests allocate: a payload that is too big, a chat history that grows without bound, a cache that never evicts. None of that applied to us. The isolate was over the limit before it served a single request, so the weight had to be in the code we shipped rather than the data we served.

This post is about what was actually in the heap, how we found it on a platform that doesn't let you attach a profiler, and the two changes that took module-scope memory from 218 MB to 82 MB and the resets to zero.

## Cloudflare Durable Objects in one minute

If you haven't used them: a Durable Object is a small, stateful, single-threaded server that Cloudflare guarantees is unique for a given ID. Call `idFromName()` with that ID from a Worker and every request for it, from anywhere in the world, lands on the same instance, with its own SQLite database, in-memory state and alarms. It hibernates when idle and wakes up where it left off.

In Polylane, we create a Durable Object instance for each thread. Every conversation with the agent, whether a person started it or an alert did, gets its own Durable Object. The object's SQLite holds the thread's messages and tool results, the agent loop runs inside it, and its tools call out to whichever providers the thread needs: Datadog, Sentry, Honeycomb, GitHub, Cloudflare and the rest. When the thread goes quiet the object hibernates, and when the next message arrives it picks up exactly where it stopped.

```mermaid
graph TB
    U["New message or alert"] --> W["Worker"]
    W -->|"idFromName(threadId)"| DO["One Durable Object per thread"]
    DO --> S["SQLite: messages, tool results"]
    DO --> L["Agent loop"]
    L --> T["Tools"]
    T --> P1["Datadog"]
    T --> P2["Sentry"]
    T --> P3["Honeycomb"]
    T --> P4["GitHub, Cloudflare, ..."]
    style DO fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

## How Durable Objects memory works

- **Isolates and the 128 MB cap.** Durable Objects run inside [V8 isolates](https://developers.cloudflare.com/workers/reference/how-workers-works/#isolates), and an isolate has a [hard memory limit of 128 MB](https://developers.cloudflare.com/workers/platform/limits/#memory) that is the same on every plan and not configurable.
- **Co-location.** [A single isolate hosts many Durable Objects of the same class](https://developers.cloudflare.com/durable-objects/observability/metrics-and-analytics/#memory-usage), along with the Worker code around them, and they all share that isolate's memory. The limit is per isolate rather than per object.
- **Noisy neighbours.** Because of co-location, the object that gets reset when the isolate [runs out of memory](https://developers.cloudflare.com/workers/platform/limits/#memory) is often not the object that used the memory. [Every memory sample Cloudflare reports is the whole isolate's](https://developers.cloudflare.com/durable-objects/observability/metrics-and-analytics/#memory-usage), and so is every reset.

```mermaid
graph TB
    subgraph ISO["One V8 isolate, 128 MB cap"]
        M["Module scope, loaded once"]
        D1["Instance A"]
        D2["Instance B"]
        D3["Instance C"]
    end
    M --- D1
    M --- D2
    M --- D3
    D3 -->|"heap crosses 128 MB"| R["exceededMemory, C is reset"]
    style M fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
    style R fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
```

There is no way to attach a memory profiler to a production Durable Object: the [heap snapshot tooling Cloudflare documents](https://developers.cloudflare.com/workers/observability/dev-tools/memory-usage/) runs against a local dev session. In Kubernetes you'd turn profiling on and attach until you found the problem. In workerd, `process.memoryUsage()` is an [unenv polyfill that returns zeros](https://github.com/unjs/unenv/blob/main/src/runtime/node/internal/process/process.ts), and the baseline memory and CPU figures aren't visible to the code running inside. What you get is Cloudflare's [GraphQL analytics API](https://developers.cloudflare.com/durable-objects/observability/metrics-and-analytics/#query-via-the-graphql-api): [memory percentiles](https://developers.cloudflare.com/durable-objects/observability/metrics-and-analytics/#memory-usage) and crash counts per namespace, after the fact.

## What the memory graph told us

Cloudflare's analytics API reports memory for a Durable Object namespace as percentiles across the isolates running it, in fifteen-minute buckets, alongside the count of exceeded-memory errors in each bucket. Every graph in this post comes from that API, filtered to the thread namespace and compared across deploy markers once we started shipping fixes.

*The median isolate (blue) steady at ~140 MB, entirely above the 128 MB limit line, with the 99th percentile near 190 MB. Every point above the dashed line is an isolate living on borrowed time.*

The median isolate, the blue line in figure 1, sat at about 140 MB for the entire week, and the 99th percentile sat near 190 MB. Both lines are above the dashed 128 MB limit, which means the typical isolate in the namespace was already past the point where Cloudflare is entitled to reset it, and was only spared until the next allocation tipped it over. What the graph did not show was any relationship to traffic. The line was as flat during the quiet hours as during the busy ones, and none of the roughly 300 resets a day came with a stack trace, because it was never our code that threw.

We looked where you would expect to look first. We read the payload sizes, we checked how chat history was truncated, and we searched the memory curve for a request pattern, and found nothing that moved the line. That flatness turned out to be the important clue. An isolate's memory is one of two things: it is **data**, meaning request payloads, chat history, tool outputs and anything else allocated while serving traffic, or it is **baseline**, meaning the objects the code itself creates when a module loads and keeps alive for the isolate's lifetime, such as imports, functions and schemas. Data rises and falls with requests, while baseline is there before the first request and never goes away, so a graph that is high and flat at idle is describing baseline. The problem had to be in what we were shipping rather than in what we were serving.

```mermaid
graph TB
    H["Isolate heap"] --> B["Baseline: built at module load"]
    H --> D["Data: allocated per request"]
    B --> B1["imports"]
    B --> B2["tool schemas"]
    B --> B3["package barrels"]
    D --> D1["payloads"]
    D --> D2["chat history"]
    D --> D3["tool outputs"]
    style B fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
```

## Why baseline memory is so easy to miss

Baseline memory hides in two places you don't normally look.

The first is the bundler. We assume tree shaking removes the code we don't use, and mostly it does, but esbuild has rules about when it can prune and they aren't the rules you'd guess. A package without `"sideEffects": false` in its `package.json` is never pruned at all. An `export *` inside a lazily evaluated module is never pruned either. A dynamic `import()` of a package root marks every export as used. We'll come back to each of these in the fixes, because they explain half of the 130 MB.

```ts
// Three ways to keep every zod schema in a package alive in the isolate,
// none of which look like a mistake.

// The package has no "sideEffects": false, so nothing in it is pruned.

// Inside a lazily evaluated module, export * is never pruned.
export * from "./zod";

// A namespace object marks every export as used, schemas included.
const pkg = await import("@scope/durable-workspaces");
```

The second is the schema library. Types feel free because they vanish at compile time, but a zod schema is not a type, it is a tree of closures built the moment its module evaluates. One mid-sized zod 4 object schema, a dozen fields with descriptions and refinements, costs ~134 KB of heap. A bare `z.string()` costs ~12 KB. The equivalent plain JSON schema object costs a few hundred bytes. None of that is in the README.

```ts
// What each of these costs the moment its module evaluates.
const Params = z.object({                     // ~134 KB for a dozen fields like these
  owner: z.string().describe("Repository owner"),
  repo: z.string().describe("Repository name"),
  pullNumber: z.number().int().describe("PR number"),
});
const Name = z.string();                      // ~12 KB

const params = {                              // a few hundred bytes
  type: "object",
  properties: { owner: { type: "string" }, repo: { type: "string" } },
} as const;
```

## We had done nothing exotic

Every one of our problems came from a default. Every agent tool declared its input with `z.object()` at module scope, because that is how the docs do it. Every internal package had a barrel `index.ts` with `export * from "./zod"`, because that is tidy. A few paths used `await import("@scope/package")` because lazy loading is supposed to be cheaper. Each one is a reasonable choice on its own. Together they were 130 MB, in an isolate with 128 to spend.

```ts
// packages/tools/src/github/get-pull-request.ts, and 250 more like it
export const parameters = z.object({
  owner: z.string().describe("Repository owner"),
  repo: z.string().describe("Repository name"),
  pullNumber: z.number().int().describe("PR number"),
});

// packages/thread-core/src/index.ts, and every other internal package
export * from "./zod";
export * from "./thread";

// packages/durable-threads/src/agent.ts
const { getWorkspaceStub } = await import("@scope/durable-workspaces");
```

## What it was costing us

Our agent has over 250 tools, which it uses through code mode and dynamic workers. Their zod definitions alone cost 78 MB of heap at module load, more than half the isolate's budget, spent on descriptions of arguments before any of them was called. Every tool we added cost another ~134 KB, whether or not that tool ever ran.

```text
250+ tools × ~134 KB of zod each   ≈  78 MB   evaluated before the first request
128 MB isolate cap − 78 MB         =  50 MB   left for every thread's actual data
```

The resets weren't free either. Every `exceededMemory` throws away an in-flight agent turn: a retry, a second model call, a user watching a spinner. At ~300 a day that stops being an incident and becomes a standing tax, and because of co-location it was paid by whichever object happened to be in the isolate at the time.

```text
Error: Durable Object's isolate exceeded its memory limit and was reset.
```

## How we found it: a heap profiler for the production bundle

We couldn't profile production, so we built a small profiler that runs locally on the exact bundle production runs. It measures the V8 heap cost of evaluating every module and prints a ranked table. We ran it in a loop: profile, remove the top of the ranking, profile again. The final check for every fix was a production deploy compared against Cloudflare's memory metrics on either side of the deploy marker.

```mermaid
graph TB
    A["wrangler deploy --dry-run"] --> B["production bundle + metafile"]
    B --> C["instrument the __esm helper"]
    C --> D["run under Node with stubs"]
    D --> E["exclusive heap per module"]
    style E fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

Three ideas make it work:

- **Same bundle.** `wrangler deploy --dry-run --outdir --metafile` emits the exact esbuild bundle a deploy would upload, plus its module graph. Everything is measured on that bundle.
- **Module attribution.** esbuild wraps lazily evaluated modules in `__esm(...)` initialiser closures. We rewrite that single helper in the built bundle so every module init records `v8.getHeapStatistics().used_heap_size` before and after itself, with an init stack separating a module's own cost (exclusive) from its dependencies' (inclusive). The result is a flamegraph-shaped dataset.
- **Controlled runs.** The instrumented bundle runs under plain Node with a loader shim that resolves `cloudflare:*` imports to stubs.

The complete recipe, ready to paste into a coding agent, is at the end of this post. The zod unit costs quoted above come from a micro-benchmark run through the same harness.

### What the first profile showed

| Source | Exclusive heap |
| --- | --- |
| `packages/tools` (250+ agent tool definitions, zod) | 78.0 MB |
| Package barrels re-exporting zod schema modules (rows below) | ~66 MB |
| &nbsp;&nbsp;&nbsp;`durable-workspaces` (workspace state and schedules) | 15.6 MB |
| &nbsp;&nbsp;&nbsp;`thread-core` (thread data layer) | 10.1 MB |
| &nbsp;&nbsp;&nbsp;`durable-automations` (automation definitions) | 9.6 MB |
| &nbsp;&nbsp;&nbsp;`durable-threads` (thread list and live updates) | 7.3 MB |
| &nbsp;&nbsp;&nbsp;`durable-automation` (one automation run) | 6.8 MB |
| &nbsp;&nbsp;&nbsp;`db` (D1 client and models) | 6.3 MB |
| &nbsp;&nbsp;&nbsp;`durable-skills` (skill definitions) | 5.7 MB |
| &nbsp;&nbsp;&nbsp;`durable-autofixes` (autofix branches and merges) | 4.9 MB |
| &nbsp;&nbsp;&nbsp;12 smaller packages | ~9 MB |

The second row is the surprising one. Those are schemas that reach the bundle only through `export * from "./zod"` in package barrels. Our code never used them, the tree shaker could not remove them, and they cost a third of the memory limit, all of it in modules that nothing ever called.

### Fix A: tool definitions as data, not code

Every tool definition declared its input schema in zod and converted it to JSON schema at runtime, because JSON schema is what the model gets sent anyway. We were building ~134 KB of closures per tool to produce a few hundred bytes of data, so we wrote the data directly.

```mermaid
graph TB
    Z["zod schema, ~134 KB per tool"] --> J["toJSONSchema() at runtime"]
    J --> M["JSON schema sent to the model"]
    D["JSON schema, ~0.3 KB per tool"] --> M
    style Z fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
    style D fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

```ts
// before: ~134 KB of closures per tool, built at module load
export const parameters = z.object({
  owner: z.string().describe("Repository owner"),
  repo: z.string().describe("Repository name"),
  pullNumber: z.number().int().describe("PR number"),
});

// after: a few hundred bytes of data, and the type comes from the schema
export const parameters = defineParameters({
  type: "object",
  properties: {
    owner: { type: "string", description: "Repository owner" },
    repo: { type: "string", description: "Repository name" },
    pullNumber: { type: "number", description: "PR number" },
  },
  required: ["owner", "repo", "pullNumber"],
} as const);
export type Input = ParametersInput<typeof parameters>;
```

`defineParameters` is a thin wrapper over the AI SDK's `jsonSchema()`. `ParametersInput` is `json-schema-to-ts` doing the type inference zod used to do. Runtime validation moved to a ~300-line validator that reproduces the properties the agent loop depends on: unknown keys stripped, defaults filled, unions resolved by discriminator, and error messages worded so the model can repair its own tool call on retry.

Module-evaluation heap went from 218.6 to 154.0 MB locally, and `packages/tools` from 78 MB to 0.7 MB. In production, resets fell from 40-110 an hour to 0-6 an hour at the deploy marker, and the median isolate's memory from ~140 MB to ~120 MB.

*Errors per bucket for the namespace (PDT). 321 in the window shown, nearly all of them before the deploy on the evening of 24 August. The stragglers afterwards are the 0-6 an hour tail that fix B removed.*

### Fix B: barrels that can actually be tree-shaken

The remaining ~66 MB was schemas the isolate never used. Three bundler behaviours explain it:

1. Without `"sideEffects": false` in a package's `package.json`, esbuild prunes nothing from it.
2. Even with the flag, `export * from "./zod"` is never pruned inside a lazily evaluated module, and anything reachable through a dynamic `import()` is lazily evaluated. Named re-exports (`export { zFoo } from "./zod"`) are pruned fine.
3. `await import("@scope/package")` materialises the package's entire namespace object, marking every export as used: schemas, classes, everything.

We confirmed each of these with a five-file fixture built with the esbuild version wrangler embeds: an entry, a package with an `index.ts` barrel, a `zod.ts` holding one schema whose constructor announces when it runs, a `do.ts` class importing that schema, and a lazy intermediary between them. The table records whether the schema's constructor ran at evaluation for each combination.

| Entry imports barrel via | Barrel re-export shape | `sideEffects: false` | Schema included |
| --- | --- | --- | --- |
| Static import | `export *` | Yes | No |
| Static import | `export *` | No | Yes |
| Dynamic `import()` of package | Any | Yes | Yes |
| Static import from a lazily loaded module | `export *` | Yes | **Yes** |
| Static import from a lazily loaded module | Named list | Yes | No |
| Any | Named list | No | Yes |

Rows 3 and 4 are the two that surprise people, and together they accounted for the ~66 MB. Here is what one barrel was doing to us:

```mermaid
graph TB
    A["do.ts imports one function"] --> B["durable-workspaces barrel"]
    B -->|"export * from './zod'"| C["zod/*.ts, 15.6 MB of schemas"]
    B -->|"used"| D["getWorkspaceStub(), ~1 KB"]
    style C fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
    style D fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

The fix is mechanical: `"sideEffects": false` in all 110+ workspace packages, rewrite the barrels' `export *` to named re-export lists, and replace `await import("@scope/package")` of package roots with static named imports.

An ablation on the real production bundle shows each piece is necessary.

| Configuration | Heap after module evaluation | Schema modules in bundle |
| --- | --- | --- |
| Baseline (after fix A) | 154.0 MB | 77 |
| `sideEffects: false` alone | 143.6 MB | 77 |
| + Static imports, barrels back to `export *` (control) | 98.6 MB | 29 |
| + Named re-export lists (full fix) | **82.1 MB** | **0** |

The control row is the interesting one: even a fully static import graph keeps 29 schema modules, so the named lists are not optional.

### The end state

*Fix A lands the evening of 24 August, fix B on 26 August (PDT). The median steps down from ~140 MB to 50-90 MB, and the 99th percentile drops below the 128 MB line for the first time.*

| | Module-scope heap (local probe) | Production median memory | Production resets |
| --- | --- | --- | --- |
| Before | 218.6 MB | ~140 MB | ~300/day |
| After A | 154.0 MB | ~120 MB | ~10/day |
| After A+B | 82.1 MB | ~70 MB | 0 |

A Durable Object that idled above the platform's memory limit now idles at barely half of it, and the ~300 daily resets are gone.

## This is not a config flag

Rewriting more than 250 tool definitions from zod to raw JSON schema, and writing a 300-line validator to replace what zod did for us, took a few days, even with coding agents. Adding `sideEffects: false` to over 100 packages and turning every `export *` into a generated named list is unglamorous work, and you typecheck the whole repo afterwards and fix what breaks. The easy alternative is to add a retry and live with the resets, and plenty of teams do. If your Durable Object is anywhere near the limit at idle, I'd argue the week is worth it, because the limit doesn't move and your tool count only goes up.

The three things I'd want to have known from day one:

- **High and flat at idle means baseline.** Profile what you ship, not what you serve.
- **Tree shaking has rules.** `sideEffects: false`, named re-exports, static imports. Miss one and the whole package rides along.
- **Schemas are code, not types.** If the consumer wants JSON schema, write JSON schema.

## Do this today to fix your Durable Object's memory

The profiler is ~100 lines with no dependencies beyond Node and wrangler. Paste the recipe below into your coding agent at the root of any repo that deploys with wrangler, read the top ten rows of the table it prints, and see what's there. Then work down the list: plain JSON schema for anything the model receives as JSON schema anyway, `"sideEffects": false` and named re-exports for the barrels, static imports for the package roots.

````markdown [skills/profile-module-scope-heap.md]
Build a per-module heap profiler for my worker's production bundle.

1. Emit the exact production bundle and metafile:

   cd <worker-dir>
   npx wrangler deploy --dry-run --env <stage> --config wrangler.jsonc \
     --outdir /tmp/heap-probe --metafile /tmp/heap-probe/meta.json

   If the worker's import graph is fully static, esbuild emits no lazy
   `__esm` wrappers and per-module attribution is impossible. In that case
   build from a probe-only entry that reaches the real entry through a
   dynamic import (and satisfies wrangler's Durable Object export check
   with a placeholder class):

   // probe-entry.ts
   export default { fetch: () => new Response("probe") };
   export class <YourDurableObjectClassName> {}
   export const probeLoad = () => import("<path-to-real-entry>");

   npx wrangler deploy --dry-run --env <stage> --config wrangler.jsonc \
     --outdir /tmp/heap-probe --metafile /tmp/heap-probe/meta.json probe-entry.ts

2. Instrument the bundle. Write instrument.mjs and run
   `node instrument.mjs /tmp/heap-probe`:

   import { readFileSync, writeFileSync } from "node:fs";
   import { join } from "node:path";

   const outDir = process.argv[2];
   const bundlePath = join(outDir, "probe-entry.js"); // or index.js
   const source = readFileSync(bundlePath, "utf-8");

   const esmHelperPattern = /var __esm = \(fn, res(?:, \w+)?\) => function __init\(\) \{[\s\S]*?\n\};\n/;
   if (!esmHelperPattern.test(source)) throw new Error("__esm helper not found; esbuild output shape changed");

   const instrumentedHelper = `var __probeInitStack = [];
   var __esm = (fn, res, err2) => function __init() {
     if (err2) throw err2[0];
     if (!fn) return res;
     const probe = globalThis.__moduleHeapProbe;
     if (!probe) {
       try { return (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }
       catch (e) { throw ((err2 = [e]), e); }
     }
     const moduleName = __getOwnPropNames(fn)[0];
     const frame = { child: 0 };
     const before = probe.heap();
     __probeInitStack.push(frame);
     try { return (res = (0, fn[moduleName])(fn = 0)), res; }
     catch (e) { throw ((err2 = [e]), e); }
     finally {
       const total = probe.heap() - before;
       __probeInitStack.pop();
       if (__probeInitStack.length > 0) __probeInitStack[__probeInitStack.length - 1].child += total;
       probe.record(moduleName, total - frame.child, total);
     }
   };
   `;
   writeFileSync(join(outDir, "instrumented.mjs"), source.replace(esmHelperPattern, instrumentedHelper));

3. Write a Node loader shim so worker-targeted code loads under Node.
   register.mjs resolves `cloudflare:*` to a stub module (an empty module
   exporting throwing placeholders for DurableObject, WorkerEntrypoint,
   env, etc.) via module.registerHooks; add a `load` hook for any
   non-JS rules in your wrangler config (for us: `.sql` files become
   text default exports, mirroring wrangler's Text rule).

4. Write probe.mjs and run it:

   node --expose-gc --import ./register.mjs probe.mjs /tmp/heap-probe

   import { join } from "node:path";
   import { pathToFileURL } from "node:url";
   import v8 from "node:v8";

   const outDir = process.argv[2];
   const records = [];
   globalThis.__moduleHeapProbe = {
     heap: () => v8.getHeapStatistics().used_heap_size,
     record: (moduleName, exclusive, total) => records.push({ moduleName, exclusive, total }),
   };
   const settle = async () => { await new Promise((r) => setTimeout(r, 0)); globalThis.gc(); globalThis.gc(); };
   const mb = (b) => (b / 1048576).toFixed(2);

   await settle();
   const bundle = await import(pathToFileURL(join(outDir, "instrumented.mjs")).href);
   if (bundle.probeLoad) await bundle.probeLoad();
   await settle();
   console.log(`heap after module evaluation: ${mb(v8.getHeapStatistics().used_heap_size)} MB`);

   for (const r of records.sort((a, b) => b.exclusive - a.exclusive).slice(0, 40))
     console.log(`${mb(r.exclusive).padStart(8)} MB  ${r.moduleName}`);

   const byPackage = new Map();
   for (const r of records) {
     const m = r.moduleName.match(/node_modules\/((?:@[^/]+\/)?[^/]+)|(packages\/[^/]+)/);
     const key = m ? (m[1] ? `npm:${m[1]}` : m[2]) : "other";
     byPackage.set(key, (byPackage.get(key) ?? 0) + r.exclusive);
   }
   for (const [k, v] of [...byPackage.entries()].sort((a, b) => b[1] - a[1]))
     if (v > 131072) console.log(`${mb(v).padStart(8)} MB  ${k}`);

5. Gotchas that will otherwise burn an afternoon:
   - The bundle's unenv polyfill replaces globalThis.process at init and its
     memoryUsage() reports zeros. Read v8.getHeapStatistics() instead.
   - Compare deltas between runs of this probe, never absolutes against
     production: Node's heap baseline differs from workerd's.
   - Modules evaluated eagerly at the top level (not wrapped in __esm) are
     invisible to attribution; the probe-entry trick in step 1 fixes that.
````

If you want the whole audit done for you, here is a skill for your coding agent:

````markdown
Audit this repo's worker bundles for module-scope schema weight, and fix what you find. Work in this order and show me numbers at every step.

1. Baseline. Using the "profile module-scope heap" recipe, build the
   production bundle of our most memory-sensitive worker and produce the
   per-module and per-package exclusive-heap ranking. Report heap after
   module evaluation.

2. Identify schema weight. From the ranking and the esbuild metafile, list
   every module matching your schema conventions (zod/valibot/etc. modules,
   e.g. packages/*/zod*) that survived into the bundle, with bytes. For each,
   compute one import chain from the entry using the metafile's `imports`
   graph (BFS), so we know *why* it is in the bundle.

3. Classify each surviving schema module:
   a. Actually used at runtime by this worker: leave it, or move the boundary.
   b. Reached through `export *` in a package barrel: candidate for named lists.
   c. Reached through `await import("<package root>")`: candidate for a static
      named import.
   d. Reached because the package lacks `"sideEffects": false`: candidate flag.

4. Apply, in this order, re-profiling after each:
   a. Add `"sideEffects": false` to every internal package that has no
      import-time side effects. Audit first: grep package sources for
      top-level globalThis mutations, addEventListener, polyfill assignment.
      Any true side-effect file gets `"sideEffects": ["./that-file.ts"]`.
   b. Rewrite `export * from "./<schemas>"` in package barrels to explicit
      `export { ... }` / `export type { ... }` lists. Generate the lists with
      the TypeScript compiler API (walk ExportDeclarations recursively,
      classify value vs type), never by regex. Typecheck the repo after.
   c. Replace every value-position `await import("@scope/pkg")` of a bare
      package root with a static named import of the symbols actually used.
      Check the site is not lazy for a *different* reason first (circular
      imports, Node-only test loading, genuine cold-path npm dependency).

5. If tool/LLM definitions build schema-library objects at module scope,
   propose converting them to plain JSON schema with types via
   json-schema-to-ts, and estimate the saving from step 1's ranking before
   doing it.

6. Verify: re-profile (report the delta), typecheck, run the affected
   packages' tests, and dry-run build every worker. Then add a CI assertion
   that reads the metafile of the memory-sensitive worker and fails if any
   schema module survives tree shaking into it, printing the import chain.

7. After deploy, compare the platform memory metrics across the deploy
   marker and report before/after median and 99th percentile memory, and reset
   counts.
````

We have also added this skill to Polylane itself, so every Polylane user gets this deep investigation into their Durable Object memory out of the box, with nothing to paste.
