[{"data":1,"prerenderedAt":6},["ShallowReactive",2],{"post-content-weakmap-weakref-javascript-cache-memory-leak":3},{"content":4,"lastModified":5},"\u003Cp>Last month a small report worker I run for a side project fell over on a Sunday. Nothing dramatic, just an out of memory crash at 4am. The worker pulls report jobs off a queue, parses each job's config, and renders a PDF. To avoid parsing the same config twice I had added a cache. One line. The fix, when I finally found it, was two characters: \u003Ccode>Map\u003C\u002Fcode> became \u003Ccode>WeakMap\u003C\u002Fcode>.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const parsedConfigCache = new Map();\n\nfunction getConfig(job) {\n  if (parsedConfigCache.has(job)) return parsedConfigCache.get(job);\n  const parsed = parseConfig(job.config);\n  parsedConfigCache.set(job, parsed);\n  return parsed;\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Spot the bug? Neither did I, and I stared at this function at least ten times. The cache is keyed by the job object itself, and the job object is exactly the thing that should die once its report finishes rendering. Instead the Map holds a strong reference to every job the worker has ever processed. A weekend batch run, forty thousand jobs, forty thousand retained job objects, 1.2GB heap. The garbage collector could not touch any of it because the cache still \"needed\" it.\u003C\u002Fp>\n\n\u003Cimg src=\"https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1516116216624-53e697fedbea?w=1200&q=80&auto=format&fit=crop\" alt=\"JavaScript code in a dark editor\" loading=\"lazy\">\n\n\u003Ch2>Reachability, in one paragraph\u003C\u002Fh2>\n\n\u003Cp>The JavaScript garbage collector frees objects that are no longer reachable. Reachable means something can still get to the object: a variable, an array slot, a closure, a Map entry. Every normal reference you create is strong, and a strong reference is a promise to the GC that says \"I still want this.\" My cache made that promise forty thousand times without meaning to. Most JavaScript memory leaks trace back to something this plain: a Map, an event listener, or a closure that outlived its owner. If you have chased leaks in a heap snapshot before, you know the retention chain is rarely exotic. It is usually one polite strong reference.\u003C\u002Fp>\n\n\u003Ch2>WeakMap fixes exactly this case\u003C\u002Fh2>\n\n\u003Cp>WeakMap holds its keys weakly. If nothing else references a key, the garbage collector collects the key and the entry vanishes on its own. No cleanup code, no delete calls, no lifecycle to manage.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const parsedConfigCache = new WeakMap();\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>That one-word change fixed the worker. Same API for get, set, has, and delete. When a job dropped out of the queue, its cache entry quietly stopped existing.\u003C\u002Fp>\n\n\u003Cp>The constraints are real, and worth knowing before you reach for it. Keys must be objects (or non-registered symbols), so no primitives. There is no size, no iteration, no keys() or values(). That is not a missing feature. If you could enumerate a WeakMap, the engine would have to keep every key alive to show it to you, which would defeat the whole point. One detail that catches people: the \u003Cem>values\u003C\u002Fem> are still held strongly. Only the key is weak. Stash a huge parsed object as a value and the WeakMap keeps it alive for as long as the key lives.\u003C\u002Fp>\n\n\u003Ch2>WeakRef for caches keyed by strings or numbers\u003C\u002Fh2>\n\n\u003Cp>WeakMap only helps when the key is an object. Plenty of real caches are keyed by a URL or an ID, and for those there is WeakRef (ES2021, in every modern runtime, Node since 14.6). It is the lower level tool: a reference you can read with deref() that does not keep its target alive.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const cache = new Map(); \u002F\u002F url -> WeakRef\n\nfunction getRendered(url) {\n  const hit = cache.get(url)?.deref();\n  if (hit) return hit;\n\n  const rendered = expensiveRender(url);\n  cache.set(url, new WeakRef(rendered));\n  return rendered;\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Two rules to live by. First, deref() returning undefined means the object was collected, so always check and rebuild. Second, and this one bit me in a preview generator once: never call deref() twice across an await. Grab the value once into a local variable, then use that. Between two separate deref() calls the GC is allowed to collect the object, and you get a confusing undefined that seems impossible.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F wrong: the second deref may return undefined after the await\nconst a = ref.deref();\nawait something();\nconst b = ref.deref(); \u002F\u002F could be undefined now\n\n\u002F\u002F right: one deref, keep the strong local reference\nconst obj = ref.deref();\nif (obj) {\n  await something();\n  obj.update(); \u002F\u002F safe\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch2>FinalizationRegistry, with low expectations\u003C\u002Fh2>\n\n\u003Cp>With a WeakRef cache, dead entries linger in the Map until you happen to look them up. FinalizationRegistry gives you a callback that runs after an object is collected, which you can use to clean those up.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">const registry = new FinalizationRegistry((url) => {\n  cache.delete(url);\n});\n\nfunction cacheRendered(url, value) {\n  cache.set(url, new WeakRef(value));\n  registry.register(value, url);\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>The held value here is the URL, not the object, and the callback must never reference the target itself. Keep your expectations low, too: the spec does not promise when, or even whether, that callback runs. Treat it as tidy up, not as a cleanup strategy. Anything that genuinely needs to happen, like closing handles or flushing buffers, needs an explicit close method.\u003C\u002Fp>\n\n\u003Ch2>The part I got wrong for a while\u003C\u002Fh2>\n\n\u003Cp>Here is my honest take after this adventure: WeakMap is not an eviction policy. If your cache keys stay alive, say a cache keyed by tenant objects that all live for the whole process, weak references do nothing and the cache still grows forever. That problem needs an LRU or a size cap. Weak references solve one narrow thing: data whose lifetime should be tied to an object you do not own. When that is your problem, they are the cleanest tool in the language. When it is not, they just hide the bug.\u003C\u002Fp>\n\n\u003Cp>If you liked this one, I also wrote about \u003Ca href=\"\u002Fposts\u002Fdeep-copy-javascript-objects-structuredclone-vs-json\u002F\">deep copying JavaScript objects\u003C\u002Fa>, another place where the obvious one-liner quietly misbehaves. And I keep the worker's cache pattern saved in \u003Ca href=\"\u002Fsnippetark\u002F\" rel=\"noopener noreferrer\">Snippet Ark\u003C\u002Fa> next to a note that says \"keys weak, values strong\". Cheap insurance against past me.\u003C\u002Fp>\n","2026-11-02",1793589940170]