Dashboard
26 août 2026

Comment nous avons corrigé les erreurs de dépassement mémoire de nos Cloudflare Durable Objects

Explore with AI

Chaque thread d’agent dans Polylane tourne à l’intérieur de son propre Cloudflare Durable Object, tous instances d’une seule classe. En août 2026, les Durable Objects de nos threads étaient réinitialisés environ 300 fois par jour pour dépassement de la limite mémoire. Nous avons entendu la même histoire de plusieurs équipes qui font tourner leurs flux agentiques sur des Cloudflare Durable Objects.

Si tu as rencontré exceededMemory sur un Durable Object, le conseil habituel est de regarder ce que tes requêtes allouent : une charge utile trop grosse, un historique de chat qui grandit sans limite, un cache qui n’évince jamais. Rien de tout cela ne s’appliquait à nous. L’isolate dépassait la limite avant de servir une seule requête, le poids devait donc se trouver dans le code que nous livrions plutôt que dans les données que nous servions.

Cet article raconte ce qu’il y avait vraiment dans le heap, comment nous l’avons trouvé sur une plateforme qui ne permet pas d’attacher un profileur, et les deux changements qui ont fait passer la mémoire au niveau des modules de 218 MB à 82 MB et les réinitialisations à zéro.

Les Cloudflare Durable Objects en une minute

Si tu ne les as jamais utilisés : un Durable Object est un petit serveur avec état, mono-thread, dont Cloudflare garantit l’unicité pour un identifiant donné. Appelle idFromName() avec cet identifiant depuis un Worker et chaque requête qui le concerne, d’où qu’elle vienne dans le monde, atterrit sur la même instance, avec sa propre base SQLite, son état en mémoire et ses alarmes. Il hiberne quand il est inactif et se réveille là où il s’était arrêté.

Dans Polylane, nous créons une instance de Durable Object pour chaque thread. Chaque conversation avec l’agent, qu’une personne l’ait démarrée ou qu’une alerte l’ait fait, reçoit son propre Durable Object. Le SQLite de l’objet contient les messages du thread et les résultats des outils, la boucle d’agent tourne à l’intérieur, et ses outils appellent les fournisseurs dont le thread a besoin : Datadog, Sentry, Honeycomb, GitHub, Cloudflare et les autres. Quand le thread se tait, l’objet hiberne, et quand le message suivant arrive, il reprend exactement où il s’était arrêté.

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

Comment fonctionne la mémoire des Durable Objects

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

Il n’y a aucun moyen d’attacher un profileur mémoire à un Durable Object en production : l’outillage de heap snapshot que Cloudflare documente s’exécute contre une session de développement locale. Dans Kubernetes, tu activerais le profilage et tu t’attacherais jusqu’à trouver le problème. Dans workerd, process.memoryUsage() est un polyfill unenv qui renvoie des zéros, et les chiffres de mémoire et de CPU de base ne sont pas visibles pour le code qui tourne à l’intérieur. Ce que tu obtiens, c’est l’API d’analytics GraphQL de Cloudflare : des percentiles de mémoire et des comptes de plantages par namespace, après coup.

Ce que le graphique de mémoire nous a dit

L’API d’analytics de Cloudflare rapporte la mémoire d’un namespace de Durable Objects sous forme de percentiles à travers les isolates qui le font tourner, par tranches de quinze minutes, avec le nombre d’erreurs de dépassement mémoire dans chaque tranche. Chaque graphique de cet article vient de cette API, filtrée sur le namespace des threads et comparée autour des marqueurs de déploiement une fois que nous avons commencé à livrer des correctifs.

Percentiles de mémoire des isolates en production pour la semaine précédant le correctif : la médiane stable autour de 140 MB, au-dessus de la ligne en pointillés de la limite de 128 MB
Figure 1
La semaine avant le correctif
L'isolate médian (bleu) stable à ~140 MB, entièrement au-dessus de la ligne de limite de 128 MB, avec le 99e percentile proche de 190 MB. Chaque point au-dessus de la ligne en pointillés est un isolate en sursis.

L’isolate médian, la ligne bleue de la figure 1, est resté à environ 140 MB pendant toute la semaine, et le 99e percentile proche de 190 MB. Les deux lignes sont au-dessus de la limite en pointillés de 128 MB, ce qui veut dire que l’isolate typique du namespace avait déjà dépassé le point où Cloudflare est en droit de le réinitialiser, et n’était épargné que jusqu’à ce que l’allocation suivante le fasse basculer. Ce que le graphique ne montrait pas, c’était une quelconque relation avec le trafic. La ligne était aussi plate pendant les heures calmes que pendant les heures chargées, et aucune des quelque 300 réinitialisations quotidiennes ne venait avec une stack trace, parce que ce n’était jamais notre code qui levait l’erreur.

Nous avons regardé là où on regarde en premier. Nous avons lu les tailles des charges utiles, vérifié comment l’historique de chat était tronqué, et cherché un motif de requêtes dans la courbe de mémoire, sans rien trouver qui fasse bouger la ligne. Cette platitude s’est avérée être l’indice important. La mémoire d’un isolate est l’une de deux choses : ce sont des données, c’est-à-dire les charges utiles des requêtes, l’historique de chat, les sorties d’outils et tout ce qui est alloué en servant du trafic, ou c’est de la base, c’est-à-dire les objets que le code lui-même crée quand un module se charge et garde en vie pendant toute la durée de l’isolate, comme les imports, les fonctions et les schémas. Les données montent et descendent avec les requêtes, alors que la base est là avant la première requête et ne disparaît jamais, donc un graphique haut et plat au repos décrit la base. Le problème devait se trouver dans ce que nous livrions plutôt que dans ce que nous servions.

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

Pourquoi la mémoire de base est si facile à manquer

La mémoire de base se cache à deux endroits où tu ne regardes pas d’habitude.

Le premier est le bundler. Nous supposons que le tree shaking retire le code que nous n’utilisons pas, et c’est en grande partie vrai, mais esbuild a des règles sur les moments où il peut élaguer, et ce ne sont pas celles que tu devinerais. Un package sans "sideEffects": false dans son package.json n’est jamais élagué du tout. Un export * à l’intérieur d’un module évalué paresseusement n’est jamais élagué non plus. Un import() dynamique de la racine d’un package marque chaque export comme utilisé. Nous reviendrons sur chacun de ces points dans les correctifs, parce qu’ils expliquent la moitié des 130 MB.

// 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.
import { getWorkspaceStub } from "@scope/durable-workspaces";

// 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");

Le second est la bibliothèque de schémas. Les types semblent gratuits parce qu’ils disparaissent à la compilation, mais un schéma zod n’est pas un type, c’est un arbre de closures construit au moment où son module s’évalue. Un schéma d’objet zod 4 de taille moyenne, une douzaine de champs avec descriptions et raffinements, coûte ~134 KB de heap. Un simple z.string() coûte ~12 KB. L’objet JSON schema équivalent coûte quelques centaines d’octets. Rien de tout cela n’est dans le README.

// 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;

Nous n’avions rien fait d’exotique

Chacun de nos problèmes venait d’un comportement par défaut. Chaque outil d’agent déclarait son entrée avec z.object() au niveau du module, parce que c’est comme ça que la documentation le fait. Chaque package interne avait un barrel index.ts avec export * from "./zod", parce que c’est propre. Quelques chemins utilisaient await import("@scope/package") parce que le chargement paresseux est censé être moins cher. Chacun est un choix raisonnable pris isolément. Ensemble, ils faisaient 130 MB, dans un isolate qui en a 128 à dépenser.

// 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");

Ce que ça nous coûtait

Notre agent a plus de 250 outils, qu’il utilise via le code mode et des workers dynamiques. Leurs définitions zod à elles seules coûtaient 78 MB de heap au chargement des modules, plus de la moitié du budget de l’isolate, dépensés en descriptions d’arguments avant qu’aucun d’eux ne soit appelé. Chaque outil que nous ajoutions coûtait ~134 KB de plus, que cet outil tourne un jour ou non.

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

Les réinitialisations n’étaient pas gratuites non plus. Chaque exceededMemory jette un tour d’agent en cours : une nouvelle tentative, un second appel au modèle, un utilisateur qui regarde un spinner. À ~300 par jour, ça cesse d’être un incident et devient une taxe permanente, et à cause de la colocation, elle était payée par l’objet qui se trouvait par hasard dans l’isolate à ce moment-là.

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

Comment nous l’avons trouvé : un profileur de heap pour le bundle de production

Nous ne pouvions pas profiler la production, alors nous avons construit un petit profileur qui tourne localement sur le bundle exact que la production exécute. Il mesure le coût en heap V8 de l’évaluation de chaque module et affiche un tableau classé. Nous l’avons fait tourner en boucle : profiler, retirer le haut du classement, profiler à nouveau. La vérification finale de chaque correctif était un déploiement en production comparé aux métriques de mémoire de Cloudflare de part et d’autre du marqueur de déploiement.

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

Trois idées le font fonctionner :

  • Le même bundle. wrangler deploy --dry-run --outdir --metafile produit le bundle esbuild exact qu’un déploiement enverrait, plus son graphe de modules. Tout est mesuré sur ce bundle.
  • L’attribution par module. esbuild enveloppe les modules évalués paresseusement dans des closures d’initialisation __esm(...). Nous réécrivons ce seul helper dans le bundle construit pour que chaque init de module enregistre v8.getHeapStatistics().used_heap_size avant et après lui-même, avec une pile d’init qui sépare le coût propre d’un module (exclusif) de celui de ses dépendances (inclusif). Le résultat est un jeu de données en forme de flamegraph.
  • Des exécutions contrôlées. Le bundle instrumenté tourne sous un Node ordinaire avec un shim de loader qui résout les imports cloudflare:* vers des stubs.

La recette complète, prête à coller dans un agent de codage, est à la fin de cet article. Les coûts unitaires de zod cités plus haut viennent d’un micro-benchmark passé par le même harnais.

Ce que le premier profil a montré

SourceHeap exclusif
packages/tools (plus de 250 définitions d’outils d’agent, zod)78.0 MB
Barrels de packages réexportant des modules de schémas zod (lignes ci-dessous)~66 MB
   durable-workspaces (état et planifications des espaces de travail)15.6 MB
   thread-core (couche de données des threads)10.1 MB
   durable-automations (définitions d’automatisations)9.6 MB
   durable-threads (liste des threads et mises à jour en direct)7.3 MB
   durable-automation (un run d’automatisation)6.8 MB
   db (client D1 et modèles)6.3 MB
   durable-skills (définitions de skills)5.7 MB
   durable-autofixes (branches et fusions d’autofix)4.9 MB
   12 packages plus petits~9 MB
Tableau 1
Le premier profil du bundle de production
Heap exclusif par source, haut du classement.

La deuxième ligne est celle qui surprend. Ce sont des schémas qui n’atteignent le bundle que par export * from "./zod" dans des barrels de packages. Notre code ne les utilisait jamais, le tree shaker ne pouvait pas les retirer, et ils coûtaient un tiers de la limite mémoire, tout cela dans des modules que rien n’appelait jamais.

Correctif A : des définitions d’outils comme données, pas comme code

Chaque définition d’outil déclarait son schéma d’entrée en zod et le convertissait en JSON schema à l’exécution, parce que le JSON schema est de toute façon ce qu’on envoie au modèle. Nous construisions ~134 KB de closures par outil pour produire quelques centaines d’octets de données, alors nous avons écrit les données directement.

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

// 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 est une fine enveloppe autour du jsonSchema() de l’AI SDK. ParametersInput, c’est json-schema-to-ts qui fait l’inférence de types que zod faisait auparavant. La validation à l’exécution est passée dans un validateur d’environ 300 lignes qui reproduit les propriétés dont la boucle d’agent dépend : clés inconnues retirées, valeurs par défaut remplies, unions résolues par discriminant, et messages d’erreur formulés pour que le modèle puisse réparer son propre appel d’outil à la tentative suivante.

Le heap à l’évaluation des modules est passé de 218.6 à 154.0 MB en local, et packages/tools de 78 MB à 0.7 MB. En production, les réinitialisations sont tombées de 40 à 110 par heure à 0 à 6 par heure au marqueur de déploiement, et la mémoire de l’isolate médian de ~140 MB à ~120 MB.

Erreurs de dépassement mémoire par tranche qui s'effondrent au déploiement du correctif A
Figure 2
Les réinitialisations s'effondrent au déploiement du correctif A
Erreurs par tranche pour le namespace (PDT). 321 dans la fenêtre affichée, presque toutes avant le déploiement du soir du 24 août. Les retardataires ensuite sont la queue de 0 à 6 par heure que le correctif B a retirée.

Correctif B : des barrels qui peuvent vraiment être tree-shakés

Les ~66 MB restants étaient des schémas que l’isolate n’utilisait jamais. Trois comportements du bundler l’expliquent :

  1. Sans "sideEffects": false dans le package.json d’un package, esbuild n’en élague rien.
  2. Même avec le flag, export * from "./zod" n’est jamais élagué à l’intérieur d’un module évalué paresseusement, et tout ce qui est atteignable via un import() dynamique est évalué paresseusement. Les réexports nommés (export { zFoo } from "./zod") sont élagués sans problème.
  3. await import("@scope/package") matérialise l’objet namespace entier du package, marquant chaque export comme utilisé : schémas, classes, tout.

Nous avons confirmé chacun de ces points avec une fixture de cinq fichiers construite avec la version d’esbuild que wrangler embarque : une entrée, un package avec un barrel index.ts, un zod.ts contenant un schéma dont le constructeur annonce quand il s’exécute, une classe do.ts important ce schéma, et un intermédiaire paresseux entre eux. Le tableau enregistre si le constructeur du schéma s’est exécuté à l’évaluation pour chaque combinaison.

L’entrée importe le barrel viaForme du réexport du barrelsideEffects: falseSchéma inclus
Import statiqueexport *OuiNon
Import statiqueexport *NonOui
import() dynamique du packageTouteOuiOui
Import statique depuis un module chargé paresseusementexport *OuiOui
Import statique depuis un module chargé paresseusementListe nomméeOuiNon
ToutListe nomméeNonOui
Tableau 2
Matrice de la fixture
Si le constructeur du module de schéma s'exécute à l'évaluation, selon la forme de l'import.

Les lignes 3 et 4 sont les deux qui surprennent, et ensemble elles expliquaient les ~66 MB. Voici ce qu’un seul barrel nous faisait :

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

Le correctif est mécanique : "sideEffects": false dans les 110 packages et plus de l’espace de travail, réécrire les export * des barrels en listes de réexports nommés, et remplacer les await import("@scope/package") de racines de packages par des imports nommés statiques.

Une ablation sur le vrai bundle de production montre que chaque pièce est nécessaire.

ConfigurationHeap après l’évaluation des modulesModules de schémas dans le bundle
Base (après le correctif A)154.0 MB77
sideEffects: false seul143.6 MB77
+ Imports statiques, barrels revenus à export * (contrôle)98.6 MB29
+ Listes de réexports nommés (correctif complet)82.1 MB0
Tableau 3
Ablation du correctif B
Heap après l'évaluation des modules et modules de schémas survivants, en ajoutant une pièce à la fois.

La ligne de contrôle est la plus intéressante : même un graphe d’imports entièrement statique conserve 29 modules de schémas, les listes nommées ne sont donc pas facultatives.

L’état final

Percentiles de mémoire des isolates en production à travers les deux déploiements, descendant par paliers sous la limite de 128 MB
Figure 3
Mémoire des isolates à travers les deux correctifs
Le correctif A arrive le soir du 24 août, le correctif B le 26 août (PDT). La médiane descend de ~140 MB à 50 à 90 MB, et le 99e percentile passe sous la ligne des 128 MB pour la première fois.
Heap au niveau des modules (sonde locale)Mémoire médiane en productionRéinitialisations en production
Avant218.6 MB~140 MB~300/jour
Après A154.0 MB~120 MB~10/jour
Après A+B82.1 MB~70 MB0
Tableau 4
Avant et après
Heap au niveau des modules d'après la sonde locale, face à la mémoire médiane des isolates en production et aux réinitialisations quotidiennes.

Un Durable Object qui tournait au repos au-dessus de la limite mémoire de la plateforme tourne maintenant au repos à peine à la moitié, et les ~300 réinitialisations quotidiennes ont disparu.

Ce n’est pas un flag de configuration

Réécrire plus de 250 définitions d’outils de zod vers du JSON schema brut, et écrire un validateur de 300 lignes pour remplacer ce que zod faisait pour nous, a pris quelques jours, même avec des agents de codage. Ajouter sideEffects: false à plus de 100 packages et transformer chaque export * en liste nommée générée est un travail ingrat, et tu typecheckes tout le dépôt ensuite et tu corriges ce qui casse. L’alternative facile est d’ajouter une nouvelle tentative et de vivre avec les réinitialisations, et beaucoup d’équipes le font. Si ton Durable Object est ne serait-ce que proche de la limite au repos, je dirais que la semaine en vaut la peine, parce que la limite ne bouge pas et que ton nombre d’outils ne fait qu’augmenter.

Les trois choses que j’aurais voulu savoir dès le premier jour :

  • Haut et plat au repos veut dire base. Profile ce que tu livres, pas ce que tu sers.
  • Le tree shaking a des règles. sideEffects: false, réexports nommés, imports statiques. Manque un seul point et tout le package embarque.
  • Les schémas sont du code, pas des types. Si le consommateur veut du JSON schema, écris du JSON schema.

Fais ceci aujourd’hui pour corriger la mémoire de ton Durable Object

Le profileur fait ~100 lignes sans autre dépendance que Node et wrangler. Colle la recette ci-dessous dans ton agent de codage à la racine de n’importe quel dépôt qui déploie avec wrangler, lis les dix premières lignes du tableau qu’il affiche, et regarde ce qu’il y a. Puis descends la liste : du JSON schema brut pour tout ce que le modèle reçoit de toute façon en JSON schema, "sideEffects": false et des réexports nommés pour les barrels, des imports statiques pour les racines de packages.

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.

Si tu veux que tout l’audit soit fait pour toi, voici un skill pour ton agent de codage :

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.

Nous avons aussi ajouté ce skill à Polylane lui-même, pour que chaque utilisateur de Polylane bénéficie d’office de cette enquête approfondie sur la mémoire de ses Durable Objects, sans rien à coller.

Personne ne devrait être on-call en 2026. Polylane surveille ton infra, enquête et répare ce qui casse.

Rejoindre la liste d'attente