<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Vasanthan K on Medium]]></title>
        <description><![CDATA[Stories by Vasanthan K on Medium]]></description>
        <link>https://medium.com/@vasanthank29?source=rss-125e2c6438de------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*WmPPGock5TlHXPOXH_yjNQ.png</url>
            <title>Stories by Vasanthan K on Medium</title>
            <link>https://medium.com/@vasanthank29?source=rss-125e2c6438de------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Wed, 04 Nov 2026 19:17:19 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@vasanthank29/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[SvelteKit 3 Migration Notes: Node 22, Vite 8 and What Actually Breaks]]></title>
            <link>https://medium.com/@vasanthank29/sveltekit-3-migration-notes-node-22-vite-8-and-what-actually-breaks-f61f9e0a112f?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/f61f9e0a112f</guid>
            <category><![CDATA[sveltekit]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[svelte]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[vites]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Wed, 04 Nov 2026 14:01:03 GMT</pubDate>
            <atom:updated>2026-11-04T14:01:03.682Z</atom:updated>
            <content:encoded><![CDATA[<p><em>A beginner-friendly field guide to upgrading from SvelteKit 2 to SvelteKit 3 — what changed, why it changed, and the quiet failures a green build will not show you.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*BKPjw7uUtpi6bW7s5qySaA.png" /></figure><p>SvelteKit 3.0 shipped on October 1, 2026, and the official line is that it feels like the same framework “with a little more polish.” That is true for your code. It is not the whole story for your upgrade.</p><p>A SvelteKit 3 upgrade is really three upgrades stacked on top of each other: a new <strong>runtime</strong> (Node 22.17+), a new <strong>build tool</strong> (Vite 8, now powered by the Rust bundler Rolldown) and a new <strong>framework</strong> major (SvelteKit 3 itself). The official codemod handles most of the framework layer beautifully. The failures tend to hide in the layers around it — your package manager, your Vite config, and links or buttons written inline in markup.</p><p>In this post we will walk through each layer slowly, with before-and-after code, the theory behind every change, and diagrams you can come back to. If you have never done a major framework migration before, you are exactly the reader I wrote this for.</p><h4>TL;DR</h4><ul><li><strong>Runtime:</strong> SvelteKit 3 needs Node 22.17 or newer, TypeScript 6, Svelte 5.57.1+ and Vite 8.0.12+. The npm bundled with Node 22 can crash while installing a fresh SvelteKit 3 project — use npm 11.6+, pnpm, or a newer Node line.</li><li><strong>Build tool:</strong> Vite 8 replaces the esbuild + Rollup pair with a single Rust bundler, Rolldown. rollupOptions becomes rolldownOptions, esbuild becomes oxc, and the object form of manualChunks is gone.</li><li><strong>Framework:</strong> svelte.config.js is gone (config moves into vite.config.ts), $lib becomes #lib, $app/stores is removed, $app/environment is now $app/env, environment variables are declared in src/env.ts, and error() takes a string message.</li><li><strong>The real risk:</strong> npx sv migrate sveltekit-3 does most of the work, but it can leave broken inline pushState calls and malformed {base} links. Always run npm run check, build, <em>and</em> click through the app.</li></ul><h3>1. Why this is three upgrades, not one</h3><p>Think of your SvelteKit app as a building with three floors. Your <strong>runtime</strong> (Node.js) is the foundation. Your <strong>build tool</strong> (Vite) is the plumbing and wiring that turns source files into something a browser and server can run. Your <strong>framework</strong> (SvelteKit) is the furnished floor you actually live on.</p><p>SvelteKit 3 raises the minimum version on every floor at once. That matters because each floor depends on the one below it. You cannot install Vite 8 on an unsupported Node version, and SvelteKit 3 will refuse to work with anything other than Vite 8.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9xuXPBLbnXOR4WQr-q9Ilg.png" /></figure><p>The practical rule that falls out of this picture: <strong>upgrade from the bottom up.</strong> First get Node and your package manager right, then make sure Vite 8 is happy, then let the SvelteKit codemod rewrite your app code. If you do it in reverse, you will spend an afternoon debugging framework errors that are really install or bundler errors in disguise.</p><blockquote><strong><em>A note on why the Svelte team bundled these together.</em></strong><em> A framework major version is the only time a project can drop old runtimes and old APIs without surprising anyone. The SvelteKit 3 announcement describes the release as the same framework with more type safety and “a little less junk.” Most of that junk is legacy API surface (like Svelte 4-era stores) and config indirection (like a separate </em><em>svelte.config.js). Raising the floor on Node and Vite is what lets the team delete it.</em></blockquote><h3>2. The version matrix: what you need before you start</h3><p>The <a href="https://svelte.dev/docs/kit/migrating-to-sveltekit-3">official migration guide</a> lists hard minimums. Here they are next to what most SvelteKit 2 projects run today, plus why each one exists.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VyWBCHyAJQdUbAMknCcMtQ.png" /></figure><p>A quick way to see where you stand:</p><pre>node -v                 # should print v22.17.0 or higher<br>npm -v                  # 11.6.0+ recommended (see section 3)<br>npx tsc -v              # Version 6.x.x<br>npm ls vite svelte @sveltejs/kit @sveltejs/vite-plugin-svelte</pre><p><strong>What each command tells you.</strong> node -v and npm -v check your runtime floor. npx tsc -v runs the TypeScript compiler installed in your project (not a global one), so it reflects what svelte-check will use. npm ls prints the resolved version of each package from your lockfile, which is more truthful than reading the ranges in package.json. A range like ^7.0.0 tells you what is <em>allowed</em>; npm ls tells you what is <em>installed</em>.</p><p><strong>Lock the runtime in your repo.</strong> Add an engines field and a packageManager field to package.json, so teammates and CI fail fast instead of half-installing:</p><pre>{<br>  &quot;engines&quot;: { &quot;node&quot;: &quot;&gt;=22.17&quot; },<br>  &quot;packageManager&quot;: &quot;npm@11.6.0&quot;<br>}</pre><p>engines documents the minimum Node version, and npm warns when it does not match. packageManager is read by Corepack, which can download and pin the exact package manager for everyone on the team. Together they turn &quot;works on my machine&quot; into a rule the tooling enforces.</p><p><strong>Upgrade to the latest 2.x first.</strong> The Svelte team recommends landing on the newest SvelteKit 2 release before jumping to 3. Late 2.x versions print targeted deprecation warnings for almost everything that is removed in 3, and SvelteKit 2.62+ already accepts configuration in vite.config.ts. Fixing those warnings on a version you know is far easier than fixing errors on one you don&#39;t.</p><h3>3. Layer 1 — Node 22 and the npm install trap</h3><p>The first thing that breaks is often not your code at all. It is npm install.</p><h4>What a runtime floor really means</h4><p>When a package declares &quot;engines&quot;: { &quot;node&quot;: &quot;&gt;=22.17&quot; }, it is promising its code only uses features that exist in Node 22.17 and later. For SvelteKit 3 that means it can delete the old @sveltejs/kit/node/polyfills module, which used to patch missing web APIs (like fetch or crypto) onto older Node versions. Those APIs now ship in Node itself, so the shim is dead weight.</p><p>If you have a custom server, remove the import:</p><pre>// server.js — SvelteKit 2 custom server<br>import &#39;@sveltejs/kit/node/polyfills&#39;; // ❌ delete this line in SvelteKit 3<br>import { handler } from &#39;./build/handler.js&#39;;</pre><p>And the getRequest / setResponse helpers from @sveltejs/kit/node are now synchronous, so drop the await:</p><pre>// Before (SvelteKit 2)<br>const request = await getRequest({ base, request: req });<br>await setResponse(res, response);<br><br>// After (SvelteKit 3)<br>const request = getRequest({ base, request: req });<br>setResponse(res, response);</pre><p><strong>Why remove </strong><strong>await?</strong> Awaiting a non-promise value is harmless in JavaScript, so this would not crash. But it signals to readers (and linters) that something asynchronous is happening when it is not. Matching the new signature keeps your code honest and avoids a lint warning about awaiting non-thenables.</p><h4>The trap: Node 22’s bundled npm</h4><p>Here is the counter-intuitive part. An independent agency <a href="https://reptile.haus/journal/sveltekit-3-node-22-npm-cannot-install-2026/">reproduced and documented</a> that a fresh SvelteKit 3 project scaffolded with Vitest can crash during npm install on npm versions 10.8.2 through 11.5.0. The error comes from deep inside npm&#39;s dependency resolver and looks something like this:</p><pre>npm error Cannot read properties of null (reading &#39;edgesOut&#39;)</pre><p>The crash disappears from npm 11.6.0 onward, and pnpm and Yarn install the same project fine. The catch is that every Node 22 release ships with an npm 10.x. So if you install Node 22 LTS and use the npm that comes with it, exactly as the docs suggest, a brand-new project may fail to install.</p><p><strong>Why your CI stays green while laptops break.</strong> npm ci installs straight from your committed lockfile. It never computes a fresh dependency tree, so it never reaches the buggy code path. npm install without a lockfile (a new clone, a dependency bump, a Docker build) does compute the tree, and that is where the crash happens.</p><h4>How to fix it</h4><p>Pick one of these, in order of preference:</p><pre># Option A: upgrade npm itself (works on Node 22)<br>npm install -g npm@^11.6.0<br><br># Option B: use pnpm via Corepack<br>corepack enable<br>pnpm install<br><br># Option C: pin Vitest to a newer major that avoids the crash<br>npm install -D vitest@^5</pre><p>Avoid reaching for --legacy-peer-deps as a quick fix. It tells npm to ignore peer-dependency rules entirely, which can silently install a different version of a transitive package than everyone else gets. If someone on your team does use it, review the lockfile diff before merging.</p><h4>Two more install-time surprises</h4><ul><li><strong>TypeScript 7 is not supported yet.</strong> SvelteKit 3 and svelte-check peer on TypeScript ^6. If your project already moved to TypeScript 7, npm install will fail with ERESOLVE. Stay on 6.x for now.</li><li><strong>Third-party plugins that pin </strong><strong>@sveltejs/kit@^2.</strong> Any package that declares a peer dependency on SvelteKit 2 only will block the install. Search your package.json and lockfile for packages that peer on @sveltejs/kit and check each one has a release that accepts ^3.</li></ul><pre># List every installed package that declares a peer on @sveltejs/kit<br>npm ls @sveltejs/kit --all</pre><h3>4. Layer 2 — Vite 8, Rolldown and Oxc</h3><p>Vite 8 is the biggest internal change in Vite’s history, yet for most SvelteKit apps it needs zero code changes. To understand when it <em>does</em> need changes, you need to know what a bundler does.</p><h4>Bundler basics in 60 seconds</h4><p>Your app is hundreds of small files that import each other. Browsers can load those files one by one, but that is slow in production. A <strong>bundler</strong> walks the import graph, combines files into a few optimised “chunks,” strips unused code (tree-shaking) and minifies the result. A <strong>transformer</strong> does the per-file work before that: turning TypeScript into JavaScript, lowering new syntax for older browsers, and so on.</p><p>Up to Vite 7, these jobs were split across two tools written in different languages: <strong>esbuild</strong> (Go) handled fast transforms and dependency pre-bundling during development, while <strong>Rollup</strong> (JavaScript) did the production build. Two engines meant two subtly different behaviours. Code that worked in npm run dev could break in npm run build.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*VK2njANpVoMhTDWBud2g_A.png" /></figure><p>Vite 8 replaces both with <strong>Rolldown</strong>, a Rust re-implementation of Rollup’s API. <strong>Oxc</strong> takes over JavaScript transforms and minification, and <strong>Lightning CSS</strong> minifies CSS. One engine for dev and prod means far fewer “only breaks in production” bugs, and teams migrating real SvelteKit sites have <a href="https://cogley.jp/articles/migrating-sveltekit-to-vite-8">reported</a> roughly 10–15% faster builds on average.</p><h4>The minimum upgrade</h4><p>If your vite.config.ts contains nothing but the SvelteKit plugin, the Vite part of the migration is only version bumps:</p><pre>npm install -D vite@^8.0.12 @sveltejs/vite-plugin-svelte@^7 vitest@^4</pre><p>The easy one to miss is @sveltejs/vite-plugin-svelte. It jumps a major version (6 → 7) to support Vite 8. If you edit package.json by hand and leave ^6, npm will never pick up 7, and you will get a peer-dependency conflict.</p><h4>Renamed config keys</h4><p>If you customised Vite, rename these. The old names still work through a compatibility layer, but they print warnings and will be removed later.</p><pre>// vite.config.ts — BEFORE (Vite 7)<br>export default defineConfig({<br>  esbuild: { drop: [&#39;console&#39;, &#39;debugger&#39;] },<br>  optimizeDeps: { esbuildOptions: { target: &#39;es2022&#39; } },<br>  build: {<br>    rollupOptions: {<br>      output: { manualChunks: { vendor: [&#39;chart.js&#39;] } }<br>    }<br>  }<br>});</pre><pre>// vite.config.ts — AFTER (Vite 8)<br>export default defineConfig({<br>  oxc: { /* JS transform options now live here */ },<br>  optimizeDeps: { rolldownOptions: { /* ... */ } },<br>  build: {<br>    rolldownOptions: {<br>      output: {<br>        // manualChunks object form is removed; let Rolldown split automatically<br>        minify: { compress: { dropConsole: true, dropDebugger: true } }<br>      }<br>    }<br>  }<br>});</pre><p><strong>Reading the diff line by line:</strong></p><ul><li>esbuild → oxc: the top-level transform config follows the tool that does the work. Note that <em>dropping</em> console calls is a minification job, so it moved under build.rolldownOptions.output.minify instead of oxc. Check the <a href="https://vite.dev/guide/migration">Vite migration guide</a> for the exact option names in your Vite minor.</li><li>optimizeDeps.esbuildOptions → optimizeDeps.rolldownOptions: dependency pre-bundling is now done by Rolldown too.</li><li>build.rollupOptions → build.rolldownOptions: same idea, same shape. Rolldown aims to accept Rollup&#39;s options.</li><li>manualChunks: { ... } (object form) is <strong>removed</strong>, and the function form is deprecated. Rolldown&#39;s automatic chunking is usually as good. If you truly need manual control, use Rolldown&#39;s newer code-splitting option.</li></ul><h4>The quiet breakage: CommonJS default imports</h4><p>This is the one change in Vite 8 that type-checking will never catch. Some older npm packages publish <strong>CommonJS</strong> (module.exports = ...) instead of ES modules. When you write import foo from &#39;old-package&#39;, the bundler has to guess whether foo should be module.exports or module.exports.default.</p><p>Vite 7 could guess differently in dev (esbuild) and prod (Rollup). Vite 8 guesses the same way everywhere. That is better in the long run, but it means a package that “worked” before may now hand you undefined:</p><pre>TypeError: Cannot read properties of undefined (reading &#39;format&#39;)</pre><p>Your TypeScript types are still correct, so svelte-check passes. Only running the code reveals it. Fixes, in order of preference: switch to the package&#39;s ES module build, replace the package with a maintained alternative, or as a short-term escape hatch:</p><pre>// vite.config.ts — temporary only, remove before shipping<br>export default defineConfig({<br>  legacy: { inconsistentCjsInterop: true }<br>});</pre><h4>Small side effects worth knowing</h4><ul><li><strong>New chunk hashes.</strong> Rolldown names and hashes chunks differently, so your first deploy invalidates every cached JS file. Expect a cold cache, not a bug.</li><li><strong>CSS may be 1–3% larger.</strong> Lightning CSS lowers syntax differently from esbuild. Usually not worth acting on.</li><li><strong>Remove dead </strong><strong>rollup overrides</strong> in package.json (for example, ones added for old Rollup CVEs). Vite 8 no longer uses Rollup, but run npm audit afterwards: Rolldown pulls in a different tree of transitive dependencies.</li></ul><h3>5. Layer 3 — SvelteKit 3’s breaking changes, explained</h3><p>This is where most of your code edits happen. I have grouped the changes by the question a beginner is likely to ask: “where does my config go?”, “how do I import my own files?”, “how do I read page state?” and so on.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*n5eNLf2Yl9jjuvJueGujUw.png" /></figure><h4>5.1 Configuration moves into vite.config.ts</h4><p><strong>Why:</strong> In SvelteKit 2, configuration was split across two files. svelte.config.js held SvelteKit and Svelte compiler options; vite.config.ts held Vite options and loaded the SvelteKit plugin. Other Vite plugins could not easily see SvelteKit&#39;s settings. Now there is one file, and the SvelteKit plugin receives its options directly.</p><pre>// svelte.config.js — SvelteKit 2 (delete this file)<br>import adapter from &#39;@sveltejs/adapter-node&#39;;<br><br>export default {<br>  compilerOptions: { runes: true },<br>  kit: {<br>    adapter: adapter(),<br>    csrf: { checkOrigin: false },<br>    prerender: { origin: &#39;https://example.com&#39; }<br>  }<br>};</pre><pre>// vite.config.ts — SvelteKit 3<br>import { sveltekit } from &#39;@sveltejs/kit/vite&#39;;<br>import adapter from &#39;@sveltejs/adapter-node&#39;;<br>import { defineConfig } from &#39;vite&#39;;<br><br>export default defineConfig({<br>  plugins: [<br>    sveltekit({<br>      adapter: adapter(),                      // was kit.adapter<br>      compilerOptions: { runes: true },        // same as before, now beside kit options<br>      csrf: { trustedOrigins: [&#39;https://checkout.stripe.com&#39;] }, // replaces checkOrigin: false<br>      paths: { origin: &#39;https://example.com&#39; } // replaces prerender.origin and adapter-node&#39;s ORIGIN<br>    })<br>  ]<br>});</pre><p><strong>What changed, conceptually:</strong> everything that lived under kit: { ... } is now a top-level option of sveltekit({ ... }). Anything SvelteKit does not recognise is passed through to vite-plugin-svelte, so options like inspector go here too.</p><p>Three options deserve a closer look:</p><ul><li><strong>csrf.checkOrigin: false is gone.</strong> CSRF (cross-site request forgery) protection rejects form posts that come from other websites. Turning it off globally was a common shortcut for payment or login providers that post back to your app. SvelteKit 3 makes protection always-on, and you allow-list the specific origins you trust with csrf.trustedOrigins.</li><li><strong>paths.origin replaces both </strong><strong>prerender.origin and adapter-node&#39;s </strong><strong>ORIGIN env var.</strong> Behind a reverse proxy (Nginx, a load balancer), the server cannot always work out its public URL from request headers. paths.origin tells it explicitly. It is also used for CSRF checks.</li><li><strong>preloadStrategy, </strong><strong>vitePlugin, </strong><strong>files.lib and several </strong><strong>experimental.* flags are removed.</strong> Delete them; the migrator does this for you.</li></ul><h4>5.2 $lib becomes #lib</h4><p><strong>Why:</strong> $lib was a SvelteKit-specific alias. Every tool (TypeScript, Vitest, ESLint, your editor) needed to be told about it. #lib uses Node&#39;s standard <strong>subpath imports</strong>: the imports field in package.json. Node, Vite and TypeScript all understand this field natively, so the alias &quot;just works&quot; everywhere.</p><pre>// package.json<br>{<br>  &quot;imports&quot;: {<br>    &quot;#lib&quot;: &quot;./src/lib/index.js&quot;,<br>    &quot;#lib/*&quot;: &quot;./src/lib/*&quot;<br>  }<br>}</pre><pre>// Before<br>import { formatDate } from &#39;$lib/utils&#39;;<br>import Button from &#39;$lib/components/Button.svelte&#39;;<br><br>// After - note the explicit file extension<br>import { formatDate } from &#39;#lib/utils.ts&#39;;<br>import Button from &#39;#lib/components/Button.svelte&#39;;</pre><p><strong>The gotcha: file extensions are now required.</strong> The &quot;#lib/*&quot;: &quot;./src/lib/*&quot; mapping is a simple text substitution. #lib/utils.ts becomes ./src/lib/utils.ts. But #lib/utils becomes ./src/lib/utils, which is not a file, and Node&#39;s resolver does not guess extensions the way bundler aliases did. The same goes for folders: write #lib/utils/index.ts, not #lib/utils. If your build fails with a module-resolution error after the migration, this is the first thing to check.</p><h4>5.3 $app/stores is removed — use $app/state</h4><p><strong>Why:</strong> Svelte 5 replaced stores with <strong>runes</strong> (fine-grained reactive state). $app/state exposes page, navigating and updated as reactive objects. You read them directly, without the $ store prefix, and components only re-render when the exact property they read changes.</p><pre>&lt;!-- Before (SvelteKit 2, store-based) --&gt;<br>&lt;script&gt;<br>  import { page } from &#39;$app/stores&#39;;<br>&lt;/script&gt;<br>&lt;p&gt;You are on {$page.url.pathname}&lt;/p&gt;<br><br><br>&lt;!-- After (SvelteKit 3, rune-based) --&gt;<br>&lt;script&gt;<br>  import { page } from &#39;$app/state&#39;;<br>&lt;/script&gt;<br>&lt;p&gt;You are on {page.url.pathname}&lt;/p&gt;</pre><p>Two behaviour changes come with it:</p><ul><li><strong>page.url is now read-only.</strong> It is typed as a ReadonlyURL, so page.url.searchParams.set(&#39;q&#39;, &#39;x&#39;) is a type error. That code never actually changed the browser URL; it only mutated a shared object. If you need a mutable copy, make one: const url = new URL(page.url.href);</li><li><strong>updated.current flips on its own.</strong> SvelteKit now checks for a new deployment on data requests, remote-function calls, when the tab regains focus, and every hour by default (version.pollInterval). If you show a &quot;New version available — reload&quot; banner, it will appear more often. That is the feature working as intended.</li></ul><h4>5.4 $app/environment is now $app/env</h4><p>A straight rename. browser, dev, building and version keep their meaning:</p><pre>// Before<br>import { browser, dev } from &#39;$app/environment&#39;;<br>// After<br>import { browser, dev } from &#39;$app/env&#39;;</pre><h4>5.5 Environment variables are declared in src/env.ts</h4><p><strong>Why:</strong> In SvelteKit 2 you chose between four modules ($env/static/private, $env/static/public, $env/dynamic/private, $env/dynamic/public) and named public variables with a PUBLIC_ prefix. Nothing checked that a required variable actually existed, so a missing API key surfaced as a runtime crash in production. SvelteKit 3 promotes the &quot;explicit environment variables&quot; feature from experimental: you declare every variable once, with validation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XnwM7mLCJz4piXJhoN82ag.png" /></figure><pre>// src/env.ts<br>import { defineEnvVars } from &#39;@sveltejs/kit/env&#39;;<br>import * as v from &#39;valibot&#39;;<br><br>export const variables = defineEnvVars({<br>  // Private by default: only importable from server code<br>  DATABASE_URL: {<br>    schema: v.pipe(v.string(), v.url()),<br>    description: &#39;Postgres connection string&#39;<br>  },<br>  // Public: safe to ship to the browser<br>  SITE_NAME: { public: true },<br>  // Plain function instead of a schema library: return the value or throw<br>  PORT: {<br>    schema: (value) =&gt; {<br>      if (value === undefined) return 3000;<br>      const port = Number(value);<br>      if (!Number.isInteger(port)) throw new Error(&#39;PORT must be an integer&#39;);<br>      return port;<br>    }<br>  },<br>  // static: inlined at build time, enabling dead-code elimination<br>  SHOW_DEBUG_OVERLAY: {<br>    public: true,<br>    static: true,<br>    schema: v.pipe(v.optional(v.string(), &#39;&#39;), v.transform((s) =&gt; s !== &#39;&#39;))<br>  }<br>});</pre><pre>// src/routes/+page.server.ts<br>import { DATABASE_URL, PORT } from &#39;$app/env/private&#39;; // server-only<br><br>// src/routes/+page.svelte<br>import { SITE_NAME } from &#39;$app/env/public&#39;;           // browser-safe</pre><p><strong>How to read this file:</strong></p><ul><li><strong>Private is the default.</strong> A variable without public: true can only be imported from $app/env/private, and SvelteKit refuses to include that module in browser code. Secrets cannot leak into a JavaScript bundle by accident.</li><li><strong>schema validates and transforms.</strong> It accepts any <a href="https://standardschema.dev">Standard Schema</a> library (Valibot, Zod) or a plain function. Notice that PORT comes out as a <em>number</em>, not a string, and gets a default. If validation fails, the app refuses to build or start, so a missing secret is caught at deploy time instead of by your users.</li><li><strong>static: true means &quot;bake it into the build.&quot;</strong> Without it, values are read when the server boots (dynamic). With it, the value is inlined, and the bundler can delete code behind {#if SHOW_DEBUG_OVERLAY} entirely when it is false.</li><li><strong>description</strong> shows up when you hover the variable in your editor. Free documentation.</li></ul><p>The old $env/* modules still work in SvelteKit 3 but are deprecated and scheduled for removal in SvelteKit 4. Migrate now while you are touching the code anyway.</p><h4>5.6 Links: base is gone, use resolve() and asset()</h4><p><strong>Why:</strong> Building URLs by string concatenation (base + &#39;/blog/&#39; + slug) is error-prone and untyped. resolve() takes a <strong>route ID</strong> plus params and returns a correct, type-checked path that already includes your base path.</p><pre>&lt;script&gt;<br>  // Before<br>  import { base, assets } from &#39;$app/paths&#39;;<br>  // After<br>  import { resolve, asset } from &#39;$app/paths&#39;;<br>&lt;/script&gt;<br><br>&lt;!-- Before --&gt;<br>&lt;a href=&quot;{base}/blog/{post.slug}&quot;&gt;Read&lt;/a&gt;<br>&lt;img src=&quot;{assets}/logo.png&quot; alt=&quot;Logo&quot; /&gt;<br>&lt;!-- After --&gt;<br>&lt;a href={resolve(&#39;/blog/[slug]&#39;, { slug: post.slug })}&gt;Read&lt;/a&gt;<br>&lt;img src={asset(&#39;logo.png&#39;)} alt=&quot;Logo&quot; /&gt;</pre><p>Note the subtle rule: <strong>route IDs start with </strong><strong>/</strong> (&#39;/blog/[slug]&#39;), but plain pathnames and asset paths do not (resolve(&#39;about&#39;), asset(&#39;logo.png&#39;)). Remember this rule — it is the root of one of the bugs in section 7.</p><h4>5.7 Navigation: pushState folds into goto</h4><p><strong>Why:</strong> Shallow routing (changing the URL and page state without loading a new page, for modals or tabs) had its own functions. SvelteKit 3 puts everything behind one API.</p><pre>import { goto, refreshAll } from &#39;$app/navigation&#39;;<br><br>// Before<br>pushState(&#39;/photos/42&#39;, { showModal: true });<br>replaceState(&#39;/photos/42&#39;, { showModal: true });<br>goto(&#39;/&#39;, { invalidateAll: true, noScroll: true, keepFocus: true, replaceState: true });<br>await invalidateAll();<br><br>// After<br>goto(&#39;/photos/42&#39;, { shallow: true, state: { showModal: true } });<br>goto(&#39;/photos/42&#39;, { shallow: true, replace: true, state: { showModal: true } });<br>goto(&#39;/&#39;, { refreshAll: true, reset: false, replace: true });<br>await refreshAll();</pre><p><strong>Option by option:</strong></p><ul><li>shallow: true = update the URL and page.state without navigating (old pushState). Add persistState: true to keep that state across a page reload.</li><li>replaceState → replace: overwrite the current history entry instead of adding one.</li><li>noScroll + keepFocus → reset: false: one switch for &quot;don&#39;t reset scroll and focus.&quot;</li><li>invalidateAll → refreshAll: re-run every load function. Unlike the old one, it does <strong>not</strong> wipe page.state, which is what you want when a modal is open.</li></ul><p>Two behaviour changes to know: goto() now <strong>rejects</strong> (throws) for URLs that do not match a route in your app, so use window.location.href = url for external sites. And shallow navigations now fire beforeNavigate, onNavigate and afterNavigate. Check navigation.shallow inside those hooks if you need to ignore them.</p><h4>5.8 Errors: simpler error(), louder handleError</h4><pre>import { error } from &#39;@sveltejs/kit&#39;;<br><br>// Before: message could be a string OR an object<br>error(404, { message: &#39;Post not found&#39;, code: &#39;POST_404&#39; });<br><br>// After: message is always a string; extra fields go third<br>error(404, &#39;Post not found&#39;, { code: &#39;POST_404&#39; });</pre><p>The fuller story:</p><ul><li><strong>App.Error always has a </strong><strong>status</strong> now, so your +error.svelte can show it without extra plumbing.</li><li><strong>handleError sees every error</strong>, including the &quot;expected&quot; ones you throw with error(). In SvelteKit 2 it only saw unexpected crashes. If you log to Sentry or similar inside handleError, filter on status or you will flood your error tracker with 404s.</li><li><strong>Rendering errors are caught.</strong> Every +error.svelte automatically becomes a Svelte error boundary, so a crash inside a component shows your error page instead of a blank screen.</li><li><strong>json() and </strong><strong>text() helpers are deprecated.</strong> Use the web standard instead: return Response.json({ ok: true }) or return new Response(&#39;hi&#39;).</li><li><strong>Form </strong><strong>fail(400, ...) now really returns 400</strong> to enhanced forms instead of 200. Tests that assert on status codes will need updating.</li></ul><h4>5.9 Smaller changes that still bite</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mUpq_wFtUjay4ZXi1tSnZQ.png" /></figure><p>The external-redirect change is a security improvement worth understanding. An <strong>open redirect</strong> happens when an attacker can make your site send users to a URL of their choosing, often from a query parameter like ?next=. Requiring { external: true } (or an allow-list of origins) forces you to make that choice deliberately:</p><pre>import { redirect } from &#39;@sveltejs/kit&#39;;<br><br>redirect(307, &#39;https://accounts.example.com/login&#39;, {<br>  external: [&#39;https://accounts.example.com&#39;] // only this origin is allowed<br>});</pre><h3>6. Running sv migrate, step by step</h3><p>You do not have to make most of the edits in section 5 by hand. The Svelte CLI (sv, which reached 1.0 alongside SvelteKit 3) ships a task-based migration that rewrites your project. Here is the full workflow, from clean branch to merge.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Lc64OtAJ60fYQMH6Lr91hg.png" /></figure><h4>Step 1 — Start from a clean, committed branch</h4><pre>git checkout -b upgrade/sveltekit-3<br>git status          # must say &quot;nothing to commit, working tree clean&quot;</pre><p><strong>Why it matters:</strong> the migrator edits files in place. A clean tree means git diff shows <em>exactly</em> what the tool changed, and git checkout . undoes it if you need to start over.</p><h4>Step 2 — Land on the latest SvelteKit 2 and fix warnings</h4><pre>npm install -D @sveltejs/kit@^2 &amp;&amp; npm run dev</pre><p>Click around the app and read the terminal. Every deprecation warning you fix here is one less mystery error after the jump.</p><h4>Step 3 — Get the runtime right</h4><pre>nvm install 22 &amp;&amp; nvm use 22   # or fnm / volta / your Docker base image<br>npm install -g npm@^11.6.0<br>node -v &amp;&amp; npm -v</pre><p>Update your CI config and Dockerfile in the same commit. A common miss is a FROM node:20-alpine line that nobody remembers until the deploy fails.</p><h4>Step 4 — Preview, then run, the migration</h4><pre># See the list of tasks (12 at the time of writing) without changing anything<br>npx sv@latest migrate sveltekit-3 --tasks<br><br># Run every task, skip the confirmation prompt, then install with npm<br>npx sv@latest migrate sveltekit-3 --tasks all --confirm --install npm</pre><p><strong>What the flags mean:</strong> --tasks on its own lists the available tasks. --tasks all selects every task (you can also name individual ones to migrate in smaller, reviewable commits). --confirm skips the interactive prompt. --install npm reinstalls dependencies afterwards with the package manager you name.</p><p>In practice the migrator bumps your dependencies, folds svelte.config.js into vite.config.ts, adds the imports field and rewrites $lib to #lib, swaps $app/stores for $app/state and $app/environment for $app/env, creates src/env.ts, moves param matchers into src/params.ts, and points tsconfig.json at $app/tsconfig. A typical diff after a successful run looks like this:</p><pre>-  import { page } from &#39;$app/stores&#39;;<br>-  import { browser } from &#39;$app/environment&#39;;<br>-  import { base } from &#39;$app/paths&#39;;<br>-  import { PUBLIC_SITE_NAME } from &#39;$env/static/public&#39;;<br>-  import { greet } from &#39;$lib/greet&#39;;<br>+  import { page } from &#39;$app/state&#39;;<br>+  import { browser } from &#39;$app/env&#39;;<br>+  import { resolve } from &#39;$app/paths&#39;;<br>+  import { PUBLIC_SITE_NAME } from &#39;$app/env/public&#39;;<br>+  import { greet } from &#39;#lib/greet.js&#39;;</pre><p>Read the diff before moving on. Commit it on its own (git commit -am &quot;chore: sv migrate sveltekit-3&quot;), so your manual fixes in the next step are reviewable separately.</p><h4>Step 5 — Work through MIGRATION_TASKS.md</h4><p>Some changes need a human decision, so the migrator writes them into a MIGRATION_TASKS.md file in your project root instead of guessing. Typical entries cover goto destinations, the new error() signature, invalidateAll → refreshAll, dev-server CORS for static files and the deprecated json() helper. The file is written step by step, so you (or a coding agent) can work through it in order. Delete it when you are done.</p><h4>Steps 6–8 — Verify three different ways</h4><p>Covered in detail in section 8. The short version: npm run check, then npm run build, then actually use the app. Each step catches a different class of bug, which is the point of the next section.</p><h3>7. What actually breaks: the gotchas a green build hides</h3><p>The migrator is good. A developer who <a href="https://medium.com/@yrastogi90/sveltekit-3-upgraded-my-app-in-one-command-two-things-broke-7c38e83745ea">tested it on a deliberately old-fashioned SvelteKit 2 app</a> found it handled every import rename and the build passed in under half a second. Then two things broke in the browser. Both share a root cause worth understanding.</p><h4>Why codemods miss things: script vs markup</h4><p>A codemod is a program that parses your source code into a tree, finds patterns, and rewrites them. Inside a &lt;script&gt; block, a call like pushState(...) is an ordinary JavaScript function call, easy to find and rewrite. Inside a Svelte template, the same call may sit in an inline event handler or inside a quoted HTML attribute string. Those are much harder to transform safely, so a cautious codemod may remove the import but leave the call. The result compiles, because templates are checked less strictly than scripts, and fails only when someone clicks.</p><h4>Gotcha 1 — inline pushState with no import</h4><pre>&lt;script&gt;<br>  import { goto } from &#39;$app/navigation&#39;;   // migrator removed pushState from here...<br>&lt;/script&gt;<br><br>&lt;!-- ...but left the call in the markup. Clicking throws: pushState is not defined --&gt;<br>&lt;button onclick={() =&gt; pushState(&#39;&#39;, { modal: true })}&gt;Open modal&lt;/button&gt;</pre><p>The fix is the new goto form:</p><pre>&lt;button onclick={() =&gt; goto(&#39;&#39;, { shallow: true, state: { modal: true } })}&gt;<br>  Open modal<br>&lt;/button&gt;</pre><p>npm run check (svelte-check) does catch this one, with an error like Cannot find name &#39;pushState&#39;. The lesson: never skip check just because build passed.</p><h4>Gotcha 2 — {base} inside a quoted href</h4><p>This is the sneaky one. Suppose you wrote a link like this in SvelteKit 2:</p><pre>&lt;a href=&quot;{base}/blog/hello-world&quot;&gt;First post&lt;/a&gt;</pre><p>The migrator can turn it into:</p><pre>&lt;a href=&quot;{resolve(&#39;&#39;)}/blog/hello-world&quot;&gt;First post&lt;/a&gt;</pre><p>After <strong>hydration</strong> (the moment SvelteKit’s JavaScript takes over the server-rendered HTML in the browser), resolve(&#39;&#39;) returns an empty string when there is no base path. The link becomes //blog/hello-world. A URL that starts with two slashes is a <strong>protocol-relative URL</strong>: it means &quot;same protocol, <em>different host</em>.&quot; The browser tries to visit a server called blog, and you get &quot;This site can&#39;t be reached.&quot;</p><p>Neither svelte-check nor the build complains, because &quot;{resolve(&#39;&#39;)}/blog/hello-world&quot; is a perfectly valid string. The fix is to use the route ID form that resolve() was designed for:</p><pre>&lt;a href={resolve(&#39;/blog/[slug]&#39;, { slug: &#39;hello-world&#39; })}&gt;First post&lt;/a&gt;</pre><p>Interestingly, links written as template literals (href={`${base}/about`}) were migrated correctly to resolve(&#39;about&#39;). Whether you hit this bug depends on how you happened to write your links. Search for the risky pattern before you migrate:</p><pre># Find {base} used inside quoted attributes<br>grep -rnE &#39;=&quot;\{base\}&#39; src/<br># Find shallow-routing calls in markup<br>grep -rnE &#39;(pushState|replaceState)\(&#39; src/ --include=*.svelte</pre><h4>Gotcha 3 — param matchers lose their types</h4><p>When matchers move from src/params/*.ts into a single src/params.ts, the parameter type can be dropped, and svelte-check reports Parameter &#39;param&#39; implicitly has an &#39;any&#39; type. Add the annotation back:</p><pre>// src/params.ts<br>import { defineParams } from &#39;@sveltejs/kit/params&#39;;<br><br>export const params = defineParams({<br>  slug: (param: string) =&gt; (/^[a-z0-9-]+$/.test(param) ? param : undefined)<br>});</pre><p>Notice the new contract: a matcher returns the <strong>parsed value</strong> when it matches and undefined when it does not, instead of returning true or false. That lets a matcher both validate <em>and</em> convert (for example, turn &quot;42&quot; into the number 42).</p><h4>Gotcha 4 — everything from section 3 and 4</h4><p>To keep the list in one place: the npm 10 install crash, TypeScript 7 conflicts, third-party plugins pinned to @sveltejs/kit@^2, and CommonJS default imports that turn into undefined under Vite 8. None of these appear in svelte-check or build output.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EwU8WYNDKBbMOG-3kkGgJg.png" /></figure><p>Read the matrix column by column and the pattern is obvious: <strong>the build column is empty.</strong> A successful build proves your code compiles under Vite 8. It says nothing about whether your links point to the right host or whether a button handler references a function that still exists.</p><h3>8. Verifying the migration: check, build, then use it</h3><p>Each of these three checks answers a different question. Run all of them.</p><h4>Check 1 — npm run check asks &quot;do my types agree?&quot;</h4><pre>npm run check<br># svelte-check found 0 errors and 0 warnings</pre><p>svelte-check runs the TypeScript compiler over your .ts files <em>and</em> your .svelte templates. It catches missing imports (Gotcha 1), lost types (Gotcha 3), mutations of the now read-only page.url, and old error(404, { message }) calls. Because SvelteKit 3 generates a stricter $app/tsconfig, expect a few new errors even in code the migrator never touched. Those are usually real bugs that were hiding.</p><h4>Check 2 — npm run build asks &quot;does it bundle under Vite 8?&quot;</h4><pre>npm run build &amp;&amp; npm run preview</pre><p>The build exercises Rolldown, your adapter and your src/env.ts validation. If a required environment variable is missing, the build stops here, which is exactly what you want. Read the output for PLUGIN_TIMINGS warnings too: Rolldown reports how much build time each Vite plugin consumes, which is useful if the upgrade did not feel faster.</p><h4>Check 3 — a smoke test asks “does it actually work in a browser?”</h4><p>The two worst bugs in section 7 only appear after hydration in a real browser. You can catch them with a manual click-through, or automate it with a tiny Playwright script. This one opens each page you list, waits for hydration, flags any link that points off-site and any JavaScript error, then clicks every button.</p><pre>// scripts/smoke.mjs — run against `npm run preview` (default port 4173)<br>import { chromium } from &#39;playwright&#39;;<br><br><br>const BASE = process.env.BASE_URL ?? &#39;http://localhost:4173&#39;;<br>const PAGES = [&#39;/&#39;, &#39;/blog&#39;, &#39;/about&#39;];      // the routes you care about<br>const problems = [];<br>const browser = await chromium.launch();<br>const page = await browser.newPage();<br>// 1. Record uncaught JavaScript errors (e.g. &quot;pushState is not defined&quot;)<br>page.on(&#39;pageerror&#39;, (err) =&gt; problems.push(`[${page.url()}] JS error: ${err.message}`));<br>for (const path of PAGES) {<br>  // 2. &#39;networkidle&#39; waits until hydration has finished and links are final<br>  await page.goto(BASE + path, { waitUntil: &#39;networkidle&#39; });<br>  // 3. Every &lt;a href&gt; should stay on our own origin (catches //blog/... links)<br>  const hrefs = await page.$$eval(&#39;a[href]&#39;, (links) =&gt; links.map((a) =&gt; a.href));<br>  for (const href of hrefs) {<br>    if (new URL(href).origin !== new URL(BASE).origin) {<br>      problems.push(`[${path}] link leaves the site: ${href}`);<br>    }<br>  }<br>  // 4. Click every visible button and let any errors surface<br>  for (const button of await page.getByRole(&#39;button&#39;).all()) {<br>    if (await button.isVisible()) await button.click({ trial: false }).catch(() =&gt; {});<br>    if (page.url() !== BASE + path) await page.goto(BASE + path, { waitUntil: &#39;networkidle&#39; });<br>  }<br>}<br>await browser.close();<br>console.log(problems.length ? problems.join(&#39;\n&#39;) : &#39;Smoke test passed&#39;);<br>process.exit(problems.length ? 1 : 0);</pre><p><strong>How it works, line by line:</strong></p><ol><li><strong>page.on(&#39;pageerror&#39;)</strong> subscribes to uncaught exceptions inside the page. A button that calls a missing pushState throws here, even though the build was green.</li><li><strong>waitUntil: &#39;networkidle&#39;</strong> matters more than it looks. The server-rendered HTML for Gotcha 2 is actually fine; the broken //blog/... href only appears once SvelteKit hydrates and re-renders the link. Waiting for the network to go quiet gives hydration time to finish.</li><li><strong>a.href</strong> (the DOM property, not the attribute) returns the <em>fully resolved</em> URL the browser would visit. Comparing its origin with ours flags any link that would leave the site. If your app links to external sites on purpose, add an allow-list here.</li><li><strong>Clicking buttons</strong> fires every inline handler once. If a click navigates away, the script returns to the page so the remaining buttons still get tested.</li></ol><p>Run it in two terminals:</p><pre>npm i -D playwright &amp;&amp; npx playwright install chromium<br>npm run build &amp;&amp; npm run preview     # terminal 1<br>node scripts/smoke.mjs               # terminal 2</pre><p>This is a smoke test, not a test suite. On a larger app, the same three ideas (listen for page errors, check link origins after hydration, exercise interactive elements) belong in your existing end-to-end tests.</p><h4>Bonus check — reproduce a fresh install</h4><p>Install-time failures never reach any of the checks above, because your machine already has a working node_modules. Simulate a new teammate:</p><pre>git clone &lt;your-repo&gt; /tmp/fresh &amp;&amp; cd /tmp/fresh<br>rm -f package-lock.json<br>npm install          # must succeed on the npm version your team actually uses</pre><h3>9. The copy-paste checklist</h3><p><strong>Before the migration</strong></p><ul><li>Branch created, working tree clean</li><li>Upgraded to the latest SvelteKit 2.x and fixed every deprecation warning</li><li>Node 22.17+ locally, in CI and in every Dockerfile</li><li>npm 11.6+ (or pnpm/Yarn); engines and packageManager set in package.json</li><li>TypeScript pinned to 6.x</li><li>Third-party packages that peer on @sveltejs/kit@^2 have a 3-compatible release</li><li>Grepped for {base} in quoted attributes and inline pushState/replaceState</li></ul><p><strong>During the migration</strong></p><ul><li>npx sv@latest migrate sveltekit-3 --tasks reviewed</li><li>npx sv@latest migrate sveltekit-3 --tasks all --confirm run and committed on its own</li><li>vite, @sveltejs/vite-plugin-svelte (7) and vitest (4+) bumped</li><li>rollupOptions → rolldownOptions, esbuild → oxc, object manualChunks removed</li><li>Every item in MIGRATION_TASKS.md done, then the file deleted</li></ul><p><strong>After the migration</strong></p><ul><li>npm run check reports 0 errors</li><li>npm run build succeeds with all environment variables validated</li><li>Smoke test or manual click-through: no off-site links, no JS errors</li><li>Fresh clone + npm install without a lockfile succeeds</li><li>handleError filters expected errors so your error tracker is not flooded</li><li>External redirect() calls pass { external: ... }</li></ul><h3>Wrapping up</h3><p>SvelteKit 3 is a genuinely pleasant upgrade at the code level. One config file instead of two, a standard #lib import, rune-based page state, validated environment variables and a single Rust-powered bundler for dev and production all make day-to-day work simpler.</p><p>The risk has simply moved. It no longer lives in your components; it lives in the layers around them: the npm your Node version happens to bundle, the peer ranges of your plugins, and URLs built inside strings that no compiler inspects. Upgrade bottom-up, run all three checks, and click through the app before you merge. Ten minutes of clicking is cheap insurance against a link that sends users to a website called blog.</p><p>One last note: <strong>remote functions</strong>, SvelteKit’s type-safe way to call server code from the client, are still experimental in 3.0 and need both experimental.remoteFunctions and Svelte&#39;s async compiler flag. That is a topic for its own post.</p><h3>References</h3><ul><li><a href="https://svelte.dev/blog/sveltekit-3-is-here">SvelteKit 3 is here</a> — Svelte blog, October 1, 2026</li><li><a href="https://svelte.dev/docs/kit/migrating-to-sveltekit-3">Migrating to SvelteKit v3</a> — official migration guide</li><li><a href="https://svelte.dev/docs/kit/environment-variables">Environment variables</a> — SvelteKit docs</li><li><a href="https://github.com/sveltejs/kit/releases">@sveltejs/kit releases</a> — GitHub</li><li><a href="https://vite.dev/guide/migration">Vite v7 to v8 migration guide</a> — Vite docs</li><li><a href="https://cogley.jp/articles/migrating-sveltekit-to-vite-8">Migrating 8 SvelteKit Sites to Vite 8 in a day</a> — Rick Cogley</li><li><a href="https://reptile.haus/journal/sveltekit-3-node-22-npm-cannot-install-2026/">SvelteKit 3 Requires Node 22. The npm in Node 22 Cannot Install It.</a> — REPTILEHAUS</li><li><a href="https://medium.com/@yrastogi90/sveltekit-3-upgraded-my-app-in-one-command-two-things-broke-7c38e83745ea">SvelteKit 3 Upgraded My App in One Command. Two Things Broke.</a> — Yash Rastogi</li><li><a href="https://www.netlify.com/changelog/2026-10-01-sveltekit-3/">SvelteKit 3 just works on Netlify</a> — Netlify changelog</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f61f9e0a112f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Node.js 26 LTS: Map Upsert, Iterator.concat and Other Small Features That Delete Utility Code]]></title>
            <link>https://medium.com/@vasanthank29/node-js-26-lts-map-upsert-iterator-concat-and-other-small-features-that-delete-utility-code-c4a993c7e77f?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/c4a993c7e77f</guid>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[nodejs]]></category>
            <category><![CDATA[ecmascript]]></category>
            <category><![CDATA[programming]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Mon, 02 Nov 2026 14:01:02 GMT</pubDate>
            <atom:updated>2026-11-02T14:01:02.697Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Node.js 26 became the new LTS line this week. The headline features are big, but the ones that will change your day-to-day code are small: two new Map methods and one new Iterator function that each replace a helper you have written many times.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wC4ddIjSbGkSb3uKlXvqqQ.png" /></figure><p>Every Node.js codebase I have worked on has a utils folder. Somewhere inside it there is a groupBy helper, a getOrCreate helper, a chain or concatIterables generator, and a date helper that adds a month without breaking on January 31. Nobody is proud of these files. They exist because the language did not give us a clean way to do very ordinary things.</p><p>Node.js 26 LTS deletes a good chunk of that folder. In this post we will walk through:</p><ol><li><strong>Map upsert</strong> — Map.prototype.getOrInsert() and getOrInsertComputed() (and the same two on WeakMap).</li><li><strong>Iterator.concat()</strong> — join any number of iterables into one lazy stream.</li><li><strong>Other small wins</strong> — Temporal switched on by default, and built-in Base64/Hex on Uint8Array.</li><li><strong>Upgrade notes</strong> — the few removals that can break your app when you move from Node.js 24.</li></ol><p>Every section starts from zero. If you know what a Map is and have written a for...of loop, you have enough background. Where an idea needs theory — why an argument is evaluated early, what &quot;lazy&quot; actually means — we will slow down and draw it.</p><h3>First, what does “Node.js 26 LTS” actually mean?</h3><p>On October 28, 2026 the Node.js project shipped <strong>v26.13.0 under the codename “Lithium”</strong> and moved the 26.x line into Long Term Support. That release contains no new code compared with 26.12.1 — it only flips the metadata. The features themselves arrived months earlier, when 26.0.0 shipped on May 5, 2026 as a “Current” release.</p><p>If the release labels are new to you, here is the short version:</p><ul><li><strong>Current</strong> — the newest major. It gets new features quickly and can still change. Good for trying things, not for production.</li><li><strong>Active LTS</strong> — the same major, now frozen for features and focused on bug and security fixes. This is the line most teams should run in production.</li><li><strong>Maintenance LTS</strong> — critical fixes only, until end of life. Node.js 26 is supported until the end of April 2029.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cJ3LflPm6MGpOl9RvDbOBg.png" /></figure><p>There is one more reason this release matters: <strong>26 is the last major on the old “two releases a year, only even numbers become LTS” schedule.</strong> Starting with Node.js 27 (April 2027), the project moves to one major release per year, and every major becomes LTS. So if you skipped Node.js 24, 26 is the natural jump.</p><h4>Which engine features did we get?</h4><p>Node.js 26 ships the <strong>V8 14.6</strong> JavaScript engine (the one inside Chromium 146). The release notes call out two new language features that come with it — and those are the stars of this post:</p><ul><li><strong>Upsert:</strong> Map.prototype.getOrInsert(), Map.prototype.getOrInsertComputed() and the same pair on WeakMap.</li><li><strong>Iterator sequencing:</strong> Iterator.concat().</li></ul><p>Both are part of the ECMAScript 2026 standard, which means they are not Node-only tricks: the same code runs in current browsers too.</p><h4>How to follow along</h4><p>Install Node.js 26 with your version manager and confirm you are on the LTS build:</p><pre>nvm install 26<br>nvm use 26<br>node -p &quot;process.version + &#39; &#39; + process.release.lts&quot;<br># v26.13.0 Lithium</pre><p>Then do a quick feature check. Each line should print function:</p><pre>// check.mjs<br>console.log(typeof Map.prototype.getOrInsert);<br>console.log(typeof Map.prototype.getOrInsertComputed);<br>console.log(typeof Iterator.concat);</pre><p>If you see undefined, you are still on an older Node.js. Every example below is a plain .mjs file you can run with node file.mjs.</p><h3>Part 1 — Map upsert: “get it, or create it” in one call</h3><p>The word <strong>upsert</strong> is borrowed from databases: <em>update</em> if the row exists, <em>insert</em> if it doesn’t. In JavaScript the idea is simpler still: <strong>give me the value for this key — and if there isn’t one yet, put a default in first.</strong></p><h4>A 30-second refresher on Map</h4><p>A Map stores key → value pairs. Unlike a plain object, its keys can be anything (strings, numbers, objects), it remembers insertion order, and it has a clean API:</p><pre>const prices = new Map();<br>prices.set(&#39;apple&#39;, 1.2);   // add or overwrite<br>prices.get(&#39;apple&#39;);        // 1.2<br>prices.get(&#39;mango&#39;);        // undefined (missing key)<br>prices.has(&#39;mango&#39;);        // false<br>prices.size;                // 1</pre><p>Notice that get() on a missing key returns undefined. That one fact is the root of the pattern we are about to delete.</p><h4>The problem: the has / set / get dance</h4><p>Say we want to count how often each word appears. Before Node.js 26 you would write something like this:</p><pre>const words = [&#39;node&#39;, &#39;map&#39;, &#39;node&#39;, &#39;iterator&#39;, &#39;map&#39;, &#39;node&#39;];<br>const counts = new Map();<br><br>for (const w of words) {<br>  if (!counts.has(w)) {      // 1. is the key there?<br>    counts.set(w, 0);        // 2. if not, insert a starting value<br>  }<br>  counts.set(w, counts.get(w) + 1); // 3. read it back and update<br>}<br>console.log(counts);<br>// Map(3) { &#39;node&#39; =&gt; 3, &#39;map&#39; =&gt; 2, &#39;iterator&#39; =&gt; 1 }</pre><p>It works, but look at what it costs:</p><ul><li><strong>The key is written three or four times.</strong> Every repetition is a chance to typo w as word in one place and not the others.</li><li><strong>The map is searched up to three times</strong> (has, set, get) for one logical operation.</li><li><strong>The intent is buried.</strong> The reader has to decode an if with a negation to understand &quot;start at zero&quot;.</li></ul><p>Grouping has exactly the same shape — check, create an empty array, read it back, push:</p><pre>if (!byCustomer.has(order.customer)) {<br>  byCustomer.set(order.customer, []);<br>}<br>byCustomer.get(order.customer).push(order.id);</pre><p>Because this is tedious, people reach for a “shortcut” that contains a classic bug:</p><pre>const groups = new Map();<br>(groups.get(&#39;asha&#39;) || []).push(1);   // looks fine...<br>console.log(groups.size);             // 0  &lt;-- the array was never stored!</pre><p>The || [] creates a brand-new array when the key is missing, you push into it, and then it is thrown away because nobody called set(). The bug is silent: no error, just missing data.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gcCb8_xSQ8NRPlVHhTeHXQ.png" /></figure><h4>Meet getOrInsert(key, defaultValue)</h4><p>getOrInsert does all three steps in one call:</p><ul><li>If key is already in the map, it <strong>returns the existing value</strong> and changes nothing.</li><li>If key is missing, it <strong>stores </strong><strong>defaultValue under </strong><strong>key</strong> and returns it.</li></ul><p>Either way, you always get a real value back — never undefined for a missing key. Here is the word counter again:</p><pre>const counts = new Map();<br><br>for (const w of words) {<br>  counts.set(w, counts.getOrInsert(w, 0) + 1);<br>}<br>console.log(counts);<br>// Map(3) { &#39;node&#39; =&gt; 3, &#39;map&#39; =&gt; 2, &#39;iterator&#39; =&gt; 1 }</pre><p>Read the loop body from the inside out: <em>“get the count for </em><em>w (starting at 0 if it&#39;s new), add one, store it.&quot;</em> One line, the key written twice instead of four times, and no if.</p><blockquote><strong><em>Why is there still a </em></strong><strong><em>set()?</em></strong><em> Numbers are </em>values<em>, not containers. </em><em>getOrInsert hands you the number </em><em>0; adding 1 to it creates a new number that the map knows nothing about. So for counters you still write the result back. For containers (arrays, Sets, objects) you don&#39;t — you mutate the container the map already holds, as the next example shows.</em></blockquote><h4>Meet getOrInsertComputed(key, callback)</h4><p>The second method takes a <strong>function</strong> instead of a ready-made value. The map only calls that function when the key is missing, and it passes the key in as the argument.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fhAZ6BHBlNjkEtG1f99VDw.png" /></figure><p>Grouping orders by customer becomes:</p><pre>const orders = [<br>  { id: 1, customer: &#39;asha&#39; },<br>  { id: 2, customer: &#39;ben&#39; },<br>  { id: 3, customer: &#39;asha&#39; },<br>];<br><br>const byCustomer = new Map();<br>for (const order of orders) {<br>  byCustomer.getOrInsertComputed(order.customer, () =&gt; []).push(order.id);<br>}<br>console.log(byCustomer);<br>// Map(2) { &#39;asha&#39; =&gt; [ 1, 3 ], &#39;ben&#39; =&gt; [ 2 ] }</pre><p>Step by step, for the first order (asha): the key is missing, so the map calls () =&gt; [], stores the new empty array under &#39;asha&#39;, and returns <strong>that same array</strong> — which we immediately .push(1) into. For the third order (asha again) the key exists, the callback is skipped, and we get the array that already holds [1]. Because it is the very same array object, pushing into it updates the map. No set() needed.</p><p>The callback receives the key, which is handy when the default depends on it:</p><pre>const squares = new Map();<br>squares.getOrInsertComputed(7, (n) =&gt; n * n); // 49 — computed and stored<br>squares.get(7);                               // 49</pre><h4>The theory: why are there two methods?</h4><p>This is the most important idea in the whole feature, so let’s slow down.</p><p>In JavaScript, <strong>every argument is evaluated before the function you’re calling starts running.</strong> When you write f(a(), b()), the engine first runs a(), then b(), and only then calls f with the two results. f has no say in the matter.</p><p>Apply that to getOrInsert:</p><pre>cache.getOrInsert(&#39;user:1&#39;, loadUser());</pre><p>loadUser() runs <strong>first</strong>, every time — even if &#39;user:1&#39; is already cached. getOrInsert then notices the key exists, returns the old value, and the freshly built object is thrown away. If loadUser() is expensive (a database query, a big allocation, a log line), you pay for it on every call.</p><p>getOrInsertComputed avoids this because you pass the <em>function itself</em>, not its result. A function is just a recipe; nothing happens until someone calls it — and the map only calls it on a miss. This idea is called <strong>lazy evaluation</strong>: do the work only when the result is actually needed.</p><p>You can prove it with a counter:</p><pre>let calls = 0;<br>const makeExpensive = () =&gt; { calls++; return { createdAt: &#39;now&#39; }; };<br>const cache = new Map([[&#39;user:1&#39;, { createdAt: &#39;earlier&#39; }]]);<br><br>cache.getOrInsert(&#39;user:1&#39;, makeExpensive());       // eager<br>console.log(calls); // 1  &lt;- ran even though the key existed<br><br>cache.getOrInsertComputed(&#39;user:1&#39;, makeExpensive); // lazy, key exists<br>console.log(calls); // 1  &lt;- callback skipped<br><br>cache.getOrInsertComputed(&#39;user:2&#39;, makeExpensive); // lazy, key missing<br>console.log(calls); // 2  &lt;- ran only on the miss</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8W7qxs9YNWKBGvJDJrsedQ.png" /></figure><p><strong>Rule of thumb:</strong></p><ul><li>Use getOrInsert for <strong>cheap constants</strong>: 0, &#39;&#39;, false, null.</li><li>Use getOrInsertComputed for <strong>anything you build</strong>: [], {}, new Set(), new Map(), a calculation, a network call.</li></ul><p>There is a second, sneakier reason for that rule. Look at what happens if you try to “optimise” by creating the empty array once:</p><pre>const EMPTY = [];<br>const groups = new Map();<br>groups.getOrInsert(&#39;asha&#39;, EMPTY).push(1);<br>groups.getOrInsert(&#39;ben&#39;, EMPTY).push(2);<br><br>console.log(groups.get(&#39;asha&#39;));                       // [ 1, 2 ]  &lt;- ben&#39;s order leaked in<br>console.log(groups.get(&#39;asha&#39;) === groups.get(&#39;ben&#39;)); // true</pre><p>Both keys now point at <strong>the same array</strong> in memory, so every push lands in every group. With getOrInsertComputed(key, () =&gt; []), the arrow function builds a fresh array on each miss, so every key gets its own.</p><h4>Real-world recipes</h4><p><strong>1. Memoization (caching function results).</strong> The callback receives the key, so a pure function can be plugged in directly:</p><pre>const memo = new Map();<br>const fib = (n) =&gt; memo.getOrInsertComputed(n, (k) =&gt; (k &lt; 2 ? k : fib(k - 1) + fib(k - 2)));<br><br>console.log(fib(40)); // 102334155 - instant, each n computed once</pre><p><strong>2. Graphs as adjacency lists.</strong> Each node maps to a Set of neighbours. The second call makes sure nodes that only appear as targets still exist:</p><pre>const edges = [[&#39;A&#39;, &#39;B&#39;], [&#39;A&#39;, &#39;C&#39;], [&#39;B&#39;, &#39;C&#39;], [&#39;C&#39;, &#39;A&#39;]];<br>const graph = new Map();<br><br>for (const [from, to] of edges) {<br>  graph.getOrInsertComputed(from, () =&gt; new Set()).add(to);<br>  graph.getOrInsertComputed(to, () =&gt; new Set());<br>}<br>// Map(3) { &#39;A&#39; =&gt; Set { &#39;B&#39;, &#39;C&#39; }, &#39;B&#39; =&gt; Set { &#39;C&#39; }, &#39;C&#39; =&gt; Set { &#39;A&#39; } }</pre><p><strong>3. Nested maps without nested </strong><strong>ifs.</strong> Because each call returns the value, you can chain them — tenant → user → session ids:</p><pre>const index = new Map();<br>for (const s of sessions) {<br>  index<br>    .getOrInsertComputed(s.tenant, () =&gt; new Map())<br>    .getOrInsertComputed(s.user, () =&gt; [])<br>    .push(s.id);<br>}<br>// Map { &#39;acme&#39; =&gt; Map { &#39;u1&#39; =&gt; [&#39;s1&#39;,&#39;s2&#39;], &#39;u2&#39; =&gt; [&#39;s3&#39;] }, &#39;globex&#39; =&gt; Map { &#39;u9&#39; =&gt; [&#39;s4&#39;] } }</pre><p>The old version of this needed two has checks, two sets and two gets.</p><p><strong>4. De-duplicating in-flight requests.</strong> If ten callers ask for the same URL at once, only one network request should go out. Store the <em>promise</em>:</p><pre>const inFlight = new Map();<br><br>function fetchOnce(url) {<br>  return inFlight.getOrInsertComputed(url, () =&gt;<br>    fetch(url).finally(() =&gt; inFlight.delete(url)) // forget it once settled<br>  );<br>}<br>await Promise.all([fetchOnce(&#39;/a&#39;), fetchOnce(&#39;/a&#39;), fetchOnce(&#39;/b&#39;)]);<br>// only 2 requests are made: one for /a, one for /b</pre><p>The laziness matters here: with getOrInsert(url, fetch(url)) every caller would fire its own request before the map could say &quot;already in flight&quot;.</p><h4>What about Map.groupBy?</h4><p>If you already have <strong>the whole array in hand</strong> and just want it grouped once, Map.groupBy(orders, (o) =&gt; o.customer) (available since Node.js 21) is even shorter. Reach for upsert when data arrives <strong>over time</strong> — events, stream chunks, messages — or when you need something other than an array per key (a counter, a Set, a nested Map, a promise).</p><h4>The WeakMap versions</h4><p>WeakMap gets the same two methods. A WeakMap only accepts objects (and non-registered symbols) as keys, and it holds them <em>weakly</em>: when nothing else in your program references the key object, the garbage collector may remove it and its value. That makes it perfect for attaching metadata to objects you don&#39;t own, without causing memory leaks:</p><pre>const meta = new WeakMap();<br>const getMeta = (obj) =&gt; meta.getOrInsertComputed(obj, () =&gt; ({ hits: 0 }));<br><br>const req = { url: &#39;/health&#39; };<br>getMeta(req).hits++;<br>getMeta(req).hits++;<br>console.log(getMeta(req).hits); // 2<br>// when `req` is no longer referenced anywhere, its metadata can be collected too</pre><p>Passing a string or number as the key throws a TypeError, exactly like WeakMap.prototype.set() does.</p><h4>Small print</h4><ul><li>getOrInsertComputed throws a TypeError if the second argument isn&#39;t a function — map.getOrInsertComputed(&#39;k&#39;, 42) fails fast instead of storing 42.</li><li>Keys are compared the same way as everywhere else in Map: primitives by value, objects by reference. Two different { id: 1 } objects are two different keys.</li><li>Keep callbacks simple. Don’t make them insert the same key they’re computing; the value the callback returns is what ends up stored.</li><li>Need this on an older runtime or browser? core-js and the es-shims packages ship polyfills for both methods.</li></ul><h3>Part 2 — Iterator.concat(): one lazy stream from many sources</h3><p>Iterator.concat(a, b, c) gives you a single iterator that yields everything from a, then everything from b, then everything from c — <strong>without copying anything into a new array first.</strong> To see why that matters, we need two words that confuse a lot of people: <em>iterable</em> and <em>iterator</em>.</p><h4>Iterables and iterators, in plain words</h4><p>Think of a music playlist.</p><ul><li>The <strong>playlist</strong> is the <em>iterable</em>: a thing that <em>can</em> be played through. Arrays, strings, Sets, Maps and generator objects are all iterables. Technically, an iterable is any object with a [Symbol.iterator]() method.</li><li>The <strong>“next track” button</strong> is the <em>iterator</em>: a cursor that remembers where you are. Each press — each call to .next() — gives you one item, as { value, done }.</li></ul><pre>const it = [10, 20][Symbol.iterator](); // ask the array for a cursor<br><br>it.next(); // { value: 10, done: false }<br>it.next(); // { value: 20, done: false }<br>it.next(); // { value: undefined, done: true }  &lt;- finished</pre><p>You rarely call .next() by hand. for...of, the spread operator [...x], Array.from(x) and destructuring all do it for you behind the scenes.</p><p>Since Node.js 22, iterators also have <strong>helper methods</strong> that work like the array methods you know — .map(), .filter(), .take(), .drop(), .flatMap(), .find(), .some(), .toArray() and friends — but they process <strong>one item at a time</strong> instead of building whole arrays. Iterator.concat() is the missing piece that lets you <em>start</em> such a chain from several sources at once.</p><h4>The problem: joining several sources</h4><p>Suppose a web framework has built-in routes in an array, plugin routes in a Set, and a generator that produces fallback routes. You want to loop over all of them. Before Node.js 26 you had two choices.</p><p><strong>Option A — spread everything into one array:</strong></p><pre>const all = [...builtIn, ...pluginRoutes, ...fallbacks()];</pre><p>Short, but <strong>eager</strong>: it reads every value from every source and allocates a brand-new array to hold them, even if you only need the first match. It cannot work at all with a source that never ends.</p><p><strong>Option B — write your own generator (again):</strong></p><pre>function* chain(...iterables) {<br>  for (const it of iterables) yield* it;<br>}<br>const all = chain(builtIn, pluginRoutes, fallbacks());</pre><p>This one is lazy, but it’s another helper to copy between projects, test and name — and it doesn’t validate its inputs until you start looping.</p><h4>Meet Iterator.concat()</h4><pre>function* lows() { yield 1; yield 2; yield 3; }<br>const highs = new Set([7, 8, 9]);<br><br>const digits = Iterator.concat(lows(), [4, 5, 6], highs);<br>console.log([...digits]); // [1, 2, 3, 4, 5, 6, 7, 8, 9]</pre><p>Three different kinds of source — a generator, an array and a Set — become one iterator. The values come out in exactly the order the sources were passed. The result is a real Iterator, so every helper method is available on it immediately.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TDL1DHfT36IawkAlL_XCLQ.png" /></figure><h4>The theory: what “lazy” really means here</h4><p>When you call Iterator.concat(...), <strong>no values are read yet.</strong> You just get back a cursor. Work only happens when somebody calls .next() on it, and then:</p><ol><li>The request is forwarded to the <strong>currently active source</strong> (at the start, the first one).</li><li>If that source has a value, it is handed straight back to the caller.</li><li>If that source is finished, Iterator.concat moves on and opens the next source — not before.</li><li>When the last source is finished, it reports { done: true }.</li></ol><p>You can watch this happen by logging from inside the sources:</p><pre>const order = [];<br>function* source(name, values) {<br>  for (const v of values) { order.push(`${name} gives ${v}`); yield v; }<br>}<br><br>const all = Iterator.concat(source(&#39;A&#39;, [1, 2]), source(&#39;B&#39;, [3, 4]));<br>order.push(&#39;nothing pulled yet&#39;);<br>const first = all.next().value;<br>order.push(`consumer got ${first}`);<br>console.log(order);<br>// [ &#39;nothing pulled yet&#39;, &#39;A gives 1&#39;, &#39;consumer got 1&#39; ]</pre><p>Notice that &#39;nothing pulled yet&#39; is logged <strong>before</strong> source A produced anything, and source B hasn&#39;t run at all. That is laziness in one picture: the consumer drives the work, one value at a time.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9KeUkARuRlHP9EVJtpi4HA.png" /></figure><h4>Combining it with iterator helpers</h4><p>This is where Iterator.concat() really earns its place. Build one stream, then shape it:</p><pre>const builtIn  = [&#39;/health&#39;, &#39;/login&#39;, &#39;/admin/users&#39;];<br>const plugins  = new Set([&#39;/blog&#39;, &#39;/admin/settings&#39;]);<br>const fallback = [&#39;/404&#39;];<br><br>const publicRoutes = Iterator.concat(builtIn, plugins, fallback)<br>  .filter((r) =&gt; !r.startsWith(&#39;/admin&#39;))<br>  .map((r) =&gt; r.toUpperCase())<br>  .toArray();<br>console.log(publicRoutes); // [ &#39;/HEALTH&#39;, &#39;/LOGIN&#39;, &#39;/BLOG&#39;, &#39;/404&#39; ]</pre><p>No intermediate arrays are created between concat, filter and map. Each route travels through the whole chain on its own; only the survivors land in the final array built by .toArray().</p><p>And because nothing is read ahead, <strong>stopping early really stops the work</strong>:</p><pre>let pulledFromSecond = 0;<br>function* second() {<br>  for (let i = 0; i &lt; 1_000_000; i++) { pulledFromSecond++; yield i; }<br>}<br><br>const firstThree = Iterator.concat([&#39;a&#39;, &#39;b&#39;], second()).take(3).toArray();<br>console.log(firstThree);       // [ &#39;a&#39;, &#39;b&#39;, 0 ]<br>console.log(pulledFromSecond); // 1  &lt;- not 1,000,000</pre><p>The same property lets you mix in <strong>infinite</strong> sources safely, as long as something downstream limits how much you take:</p><pre>function* naturals() { let n = 1; while (true) yield n++; }<br><br>Iterator.concat([&#39;start&#39;], naturals()).take(5).toArray();<br>// [ &#39;start&#39;, 1, 2, 3, 4 ]</pre><h4>More everyday uses</h4><p><strong>Merging Maps (later sources win).</strong> A Map iterates as [key, value] pairs, and the Map constructor accepts pairs, so concatenating maps <em>is</em> merging them:</p><pre>const defaults = new Map([[&#39;port&#39;, 3000], [&#39;host&#39;, &#39;localhost&#39;]]);<br>const fromEnv  = new Map([[&#39;port&#39;, 8080]]);<br><br>const config = new Map(Iterator.concat(defaults, fromEnv));<br>// Map(2) { &#39;port&#39; =&gt; 8080, &#39;host&#39; =&gt; &#39;localhost&#39; }</pre><p><strong>Reading paged data as one stream.</strong> If each page is a generator, the caller never has to think about page boundaries:</p><pre>function* page(n) { for (let i = 1; i &lt;= 3; i++) yield `p${n}-item${i}`; }<br><br>Iterator.concat(page(1), page(2), page(3)).take(5).toArray();<br>// [ &#39;p1-item1&#39;, &#39;p1-item2&#39;, &#39;p1-item3&#39;, &#39;p2-item1&#39;, &#39;p2-item2&#39; ]</pre><p>Page 3 is never touched.</p><h4>Rules and gotchas</h4><p><strong>1. An iterator can only be read once.</strong> It’s a cursor, not a container. Once it reaches the end, it stays there:</p><pre>const once = Iterator.concat([1, 2]);<br>console.log([...once]); // [ 1, 2 ]<br>console.log([...once]); // []  &lt;- already exhausted</pre><p>If you need to loop twice, call Iterator.concat(...) twice (or .toArray() once and reuse the array).</p><p><strong>2. Every argument must be an iterable <em>object</em> — checked immediately.</strong> The arguments are validated the moment you call Iterator.concat, not later when you start looping. Two cases surprise people:</p><pre>Iterator.concat(&#39;ab&#39;);   // TypeError — a string is a primitive, not an object<br><br>const bare = {           // has next() but no [Symbol.iterator]()<br>  i: 0,<br>  next() { return this.i &lt; 2 ? { value: this.i++, done: false } : { value: undefined, done: true }; },<br>};<br>Iterator.concat(bare);   // TypeError - an iterator that isn&#39;t iterable</pre><p>The fix for both is Iterator.from(), which wraps anything iterable-ish into a proper Iterator:</p><pre>[...Iterator.concat(Iterator.from(&#39;ab&#39;), [1])]; // [ &#39;a&#39;, &#39;b&#39;, 1 ]<br>[...Iterator.concat(Iterator.from(bare))];      // [ 0, 1 ]</pre><p>Why so strict? Iterator.concat <em>takes ownership</em> of the iterators it opens and promises to close them. With a bare iterator it would be unclear who is responsible for closing it, so the standard simply refuses.</p><p><strong>3. Stopping early closes the active source for you.</strong> If you break out of a loop (or .take() stops), the source currently being read gets its cleanup code run — the finally block of a generator, for example. Sources that were never reached are never opened, so there is nothing to clean up:</p><pre>function* resource(name) {<br>  try { yield `${name}-1`; yield `${name}-2`; }<br>  finally { console.log(`${name} closed`); }<br>}<br><br>for (const v of Iterator.concat(resource(&#39;fileA&#39;), resource(&#39;fileB&#39;))) {<br>  console.log(&#39;read&#39;, v);<br>  if (v === &#39;fileA-1&#39;) break;<br>}<br>// read fileA-1<br>// fileA closed      &lt;- fileB was never opened</pre><p>This is the behaviour you want for file handles, database cursors and anything else that must be released.</p><p><strong>4. It is synchronous only.</strong> Async generators and streams are <em>async</em> iterables and will throw a TypeError. For those, a tiny async function* with for await and yield* is still the tool.</p><p><strong>5. Huge or unknown numbers of sources?</strong> Iterator.concat(...arrayOfIterables) spreads every source into arguments. When you have thousands of sources — or an endless list of them — use .flatMap() on an iterator of iterables instead: Iterator.from(listOfSources).flatMap((s) =&gt; s).</p><h3>Part 3 — Other small features that delete utility code</h3><p>Map upsert and Iterator.concat() are the new language features in V8 14.6. Two more changes make Node.js 26 LTS a good moment to clean out the utils folder — especially if you&#39;re jumping straight from Node.js 24.</p><h4>Temporal is on by default</h4><p>Temporal is the modern replacement for JavaScript&#39;s Date object, and Node.js 26 is the first release where it is <strong>enabled by default</strong> — no flag, no polyfill. It deserves its own article, so here are only the parts that replace common helpers.</p><p>The problem with Date in one example — adding a month to January 31:</p><pre>const d = new Date(2026, 0, 31); // Jan 31, 2026<br>d.setMonth(d.getMonth() + 1);<br>console.log(d.toDateString());   // &#39;Tue Mar 03 2026&#39;  &lt;- overflowed into March</pre><p>Date happily creates &quot;February 31&quot; and rolls it forward three days. Most teams have a helper (or a library) just to avoid this. With Temporal:</p><pre>const jan31 = Temporal.PlainDate.from(&#39;2026-01-31&#39;);<br><br>jan31.add({ months: 1 }).toString();  // &#39;2026-02-28&#39;  &lt;- clamped to the last valid day<br>jan31.toString();                     // &#39;2026-01-31&#39;  &lt;- the original is untouched</pre><p>Two ideas make Temporal easier to reason about than Date:</p><ul><li><strong>Separate types for separate questions.</strong> A PlainDate is a calendar date with no time and no time zone (a birthday, a due date). A ZonedDateTime is an exact moment in a specific zone (a meeting). You pick the type that matches your data, so a birthday can never shift by a day because a server runs in UTC.</li><li><strong>Everything is immutable.</strong> Methods like .add() return a <em>new</em> object instead of changing the old one, so you can&#39;t accidentally modify a date someone else is holding.</li></ul><p>A few more one-liners that used to need helpers:</p><pre>// Date arithmetic<br>Temporal.PlainDate.from(&#39;2026-10-30&#39;).add({ days: 45 }).toString();<br>// &#39;2026-12-14&#39;<br><br>// &quot;How long until...?&quot; as a human-readable duration<br>Temporal.PlainDate.from(&#39;2026-10-28&#39;)<br>  .until(&#39;2029-04-30&#39;, { largestUnit: &#39;years&#39; })<br>  .toString();<br>// &#39;P2Y6M2D&#39;  -&gt; 2 years, 6 months, 2 days<br><br>// Time-zone conversion<br>Temporal.ZonedDateTime.from(&#39;2026-11-02T09:00[America/New_York]&#39;)<br>  .withTimeZone(&#39;Asia/Kolkata&#39;)<br>  .toString();<br>// &#39;2026-11-02T19:30:00+05:30[Asia/Kolkata]&#39;<br><br>// Today&#39;s date in the system time zone<br>Temporal.Now.plainDateISO();</pre><p>You don’t have to rewrite every Date in your codebase. A good first step is to use Temporal in new code that does calendar maths or crosses time zones — exactly the places where date libraries and hand-written helpers live today.</p><h4>Base64 and Hex built into Uint8Array</h4><p>These methods landed in Node.js 25, so <strong>Node.js 26 is the first LTS that has them.</strong> They let any Uint8Array convert to and from Base64 and hex strings directly:</p><pre>const bytes = new TextEncoder().encode(&#39;Hello, Node 26!&#39;);<br><br>bytes.toBase64(); // &#39;SGVsbG8sIE5vZGUgMjYh&#39;<br>bytes.toHex();    // &#39;48656c6c6f2c204e6f646520323621&#39;<br><br>const back = Uint8Array.fromBase64(&#39;SGVsbG8sIE5vZGUgMjYh&#39;);<br>new TextDecoder().decode(back); // &#39;Hello, Node 26!&#39;<br><br>Uint8Array.fromHex(&#39;cafe&#39;);     // Uint8Array(2) [ 202, 254 ]</pre><p>URL-safe Base64 (for tokens, JWT segments and file names) is an option, not another helper:</p><pre>const raw = new TextEncoder().encode(&#39;Hi?&gt;&#39;);<br><br>raw.toBase64();                                           // &#39;SGk/Pg==&#39;<br>raw.toBase64({ alphabet: &#39;base64url&#39;, omitPadding: true }); // &#39;SGk_Pg&#39;</pre><p>In pure Node.js code, Buffer.from(bytes).toString(&#39;base64&#39;) still works fine. The win is <strong>portability</strong>: the same lines run unchanged in current browsers, where these methods are part of the standard too. If you share code between a server and a frontend, you can delete the btoa(String.fromCharCode(...bytes)) dance and the per-platform if (typeof Buffer !== &#39;undefined&#39;) branches.</p><h3>Part 4 — Before you upgrade: what can break</h3><p>Small features are the fun part, but an LTS jump is also when old code stops working. These are the changes from the Node.js 26.0.0 release notes most likely to affect an app coming from Node.js 24:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Cva2quVj-MLcy0q8ovJOHQ.png" /></figure><p>One more operational note from the LTS announcement: since Node.js 25, the official Linux binaries are linked against libatomic, which some slim container images don&#39;t include. If node fails to start in Docker after the upgrade, install that package first.</p><p>A safe rollout path: run your test suite on Node.js 26 in CI alongside 24, fix what fails, then switch production. Once you’re on 26, search the codebase for the old patterns (.has( followed by .set(, function* chain, yield* helpers, btoa() and replace them as you touch each file.</p><h3>Cheat sheet: what to delete from your utils folder</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QUzJoQ--8oJYSNgWK9E4dQ.png" /></figure><h3>Wrapping up</h3><p>None of these features will make a conference keynote. That’s the point. They remove small, repeated pieces of code that every team writes slightly differently — and every different version is a place for a bug to hide.</p><p>If you remember only three things:</p><ol><li><strong>getOrInsertComputed(key, () =&gt; default) is the safe default</strong> for &quot;get it, or create it&quot;. Use getOrInsert(key, value) only for cheap constants like 0, because its argument is always evaluated.</li><li><strong>Iterator.concat(a, b, c) joins sources lazily.</strong> Nothing is read until you ask, stopping early really stops, and cleanup runs for you. Wrap strings and bare iterators in Iterator.from() first.</li><li><strong>Node.js 26 is the LTS to move to.</strong> It’s supported until April 2029 and is the last release on the old schedule. Check the removals table above, then start deleting helpers.</li></ol><h3>Sources</h3><ul><li><a href="https://nodejs.org/en/blog/release/v26.0.0">Node.js 26.0.0 (Current) release notes</a> — V8 14.6, upsert, Iterator.concat(), Temporal, removals</li><li><a href="https://nodejs.org/en/blog/release/v26.13.0">Node.js 26.13.0 ‘Lithium’ (LTS) release notes</a> — LTS date and support window</li><li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/getOrInsertComputed">MDN: </a><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/getOrInsertComputed">Map.prototype.getOrInsertComputed()</a></li><li><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/concat">MDN: </a><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/concat">Iterator.concat()</a></li><li><a href="https://tc39.es/proposal-iterator-sequencing/">TC39 iterator sequencing specification</a> — argument validation rules</li><li><a href="https://appwrite.io/blog/post/nodejs-v25-whats-new">What’s new in Node.js 25 (Appwrite)</a> — Uint8Array Base64/Hex arrival</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c4a993c7e77f" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building the Memory Layer of My Agent Harness: What I Learned Wiring Persistent Context by Hand]]></title>
            <link>https://medium.com/@vasanthank29/building-the-memory-layer-of-my-agent-harness-what-i-learned-wiring-persistent-context-by-hand-d47ec22f33f7?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/d47ec22f33f7</guid>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[large-language-models]]></category>
            <category><![CDATA[agentic-ai]]></category>
            <category><![CDATA[typescript]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Fri, 30 Oct 2026 13:01:03 GMT</pubDate>
            <atom:updated>2026-10-30T13:01:03.023Z</atom:updated>
            <content:encoded><![CDATA[<p><em>An LLM forgets everything the moment a request ends. Here is how I built a memory layer for my multi-agent harness in TypeScript, from a plain interface to semantic recall, token budgets and forgetting, and the mistakes I made along the way.</em></p><p>A few weeks into building my own agent harness, I asked my coding agent to “use the same folder structure as last time.” It answered politely and confidently, and it was completely wrong. There was no “last time” for it. Every request my harness sent to the model started from a blank slate.</p><p>That moment changed what I was building. I had a router and a small team of specialized agents working, inspired by tools like Ruflo (formerly claude-flow). But without memory, my “team” behaved like a group of brilliant contractors who had never met each other and had never seen the project before. Each one did good work in isolation, then forgot all of it.</p><p>I could have installed an existing memory library and moved on. Instead, I decided to wire persistent context by hand. My goal was not to build the best memory system in the world. My goal was to understand what a memory system actually does, piece by piece, so that the next time I use a framework I know what is happening underneath.</p><p>This post is the write-up of that journey. By the end you will understand:</p><ul><li>Why language models need an external memory at all, and what the context window really is</li><li>The four kinds of memory an agent can have, and which ones are worth building first</li><li>How to design a MemoryStore interface and back it with SQLite</li><li>How embeddings and cosine similarity let an agent recall things by meaning, not by keywords</li><li>How to rank memories by relevance, recency and importance</li><li>How to pack the best memories into a prompt without blowing your token budget</li><li>How lifecycle hooks write new memories back automatically</li><li>How to share memory safely between agents, and how to make the system forget</li></ul><p>I have written this for beginners. If you know basic TypeScript and have called an LLM API once, you can follow every step. Each code block is followed by an explanation of what it does and, more importantly, why it is designed that way.</p><h3>Why an agent needs memory at all</h3><p>An LLM is stateless: it remembers nothing between API calls, so any continuity you see is something the application rebuilt and sent back in.</p><p>This surprises a lot of people, because chat apps feel like they remember. They do not. When you chat with a model, the app quietly re-sends the whole conversation with every new message. The model reads it from the top each time, like an actor who is handed the full script before every single line.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V_IOMKN6UFNOdtMqR_at0A.png" /></figure><p><em>The same two requests, with and without a memory layer. The model is identical in both columns; only the prompt changes.</em></p><h4>The context window is RAM, not a hard disk</h4><p>The best mental model I found is to treat the context window like a computer’s RAM. It is fast, the model can “see” everything in it at once, and it is the only place the model can think. But it is also limited and it is wiped after every request.</p><p>A memory layer is the hard disk. It stores everything durable outside the model, and before each request it loads only the small slice that matters into RAM. That is the entire idea. Everything else in this post is detail about doing that loading well.</p><p>This analogy explains three constraints that shaped my design:</p><ol><li><strong>Space is limited.</strong> Even with very large context windows, every token costs money and latency. Worse, models get measurably less accurate when the important fact is buried in the middle of a huge prompt. More context is not automatically better context.</li><li><strong>Loading is selective.</strong> You cannot load the whole disk. You need a way to decide which memories are worth the space for <em>this</em> request.</li><li><strong>Writing is deliberate.</strong> Nothing is saved automatically. If you want the agent to remember something tomorrow, some code must decide to write it down today.</li></ol><h4>Why it is harder in a multi-agent harness</h4><p>My harness has a router that sends each task to one of several specialized agents: a planner, a coder and a reviewer. With a single chatbot, “memory” mostly means chat history. With a team of agents, new questions appear:</p><ul><li>Should the reviewer see what the coder learned? (Usually yes.)</li><li>Should the coder’s scratch notes leak into the planner’s prompt? (Usually no.)</li><li>If two agents learn the same fact, do we store it twice?</li><li>If a fact becomes wrong (we switched from npm to pnpm), who updates it?</li></ul><p>These questions forced me to think about memory as a proper subsystem with its own interface, its own storage and its own rules, not as an array of messages I append to.</p><h3>The mental model: four kinds of memory</h3><p>Not all memory is the same, and treating it as one big bucket was my first mistake. Cognitive scientists split human memory into types, and that split maps surprisingly well onto agents.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*cnoStekxGcl5kKxK_E03mg.png" /></figure><p><em>Working and session memory live inside the prompt. Semantic and episodic memory live outside it and are recalled on demand.</em></p><p><strong>1. Working memory</strong> is simply what is in the prompt right now: the system prompt, the tool definitions, the user’s task. It exists for exactly one API call. You already have this; every LLM app does.</p><p><strong>2. Session memory</strong> is the recent history of the current conversation or task. In a chat app this is the message list. It is the easiest kind to implement (keep an array, send it back each time) and it is where most tutorials stop. Its problem is that it grows without limit and dies when the session ends.</p><p><strong>3. Semantic memory</strong> holds facts and preferences that stay true across sessions. “This project uses pnpm.” “Vasanthan prefers TypeScript strict mode.” “The API base URL is set in config/env.ts.&quot; These are not tied to any particular moment. They are knowledge.</p><p><strong>4. Episodic memory</strong> holds records of things that <em>happened</em>. “Last Tuesday the refactor of the router broke three tests because of a circular import.” Episodes carry lessons. They help an agent avoid repeating a mistake, which is something semantic facts alone cannot do.</p><p>Some writers add a fifth type, <strong>procedural memory</strong>, meaning learned skills or workflows (“how we deploy”). In my harness I store procedures as a special kind of semantic memory, so I will not treat it separately here.</p><h4>Which ones should you build first?</h4><p>Working and session memory you get almost for free. The interesting engineering is in semantic and episodic memory, because they live outside the model and must be <em>recalled</em>. That recall step is where all the hard questions live: what to fetch, how much, and in what order.</p><p>So the rest of this post is about building an external store for semantic and episodic memories and a pipeline that moves the right ones into the prompt at the right time.</p><h3>The architecture: where memory sits in the harness</h3><p>The single most useful decision I made was to put one component, the MemoryManager, between the agents and everything memory-related. Agents never touch the database, the embedding model or the hooks directly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SWZc9xUmsSRR_LknzqxwPQ.png" /></figure><p><em>Each agent has its own namespace, but all memory traffic flows through one MemoryManager.</em></p><p>Here is what each piece does:</p><ul><li><strong>Router.</strong> Reads the incoming task and picks the agent best suited to it. It existed before memory did.</li><li><strong>Agents (planner, coder, reviewer).</strong> Each one has a system prompt, a set of tools and a <em>namespace</em> such as agent:coder. The namespace is like a folder label on every memory the agent writes.</li><li><strong>MemoryManager.</strong> The public API of the memory layer. It exposes five verbs: recall() finds candidate memories, rank() orders them, buildContext() packs the winners into a token budget, remember() writes new ones, and decay() handles forgetting.</li><li><strong>SQLite store.</strong> Durable storage on disk. One file, zero servers.</li><li><strong>Embedder.</strong> Turns text into a vector (a list of numbers) so we can search by meaning.</li><li><strong>Hooks.</strong> Functions the harness calls at fixed moments in the agent lifecycle, such as after every turn or at the end of a session. This is where automatic memory writing lives.</li></ul><h4>Why a manager instead of letting agents query the DB?</h4><p>This is classic separation of concerns, and it paid off three times:</p><ol><li><strong>Swappable storage.</strong> I started with an in-memory array, moved to SQLite, and could move to Postgres with pgvector later. The agents never noticed.</li><li><strong>One place for policy.</strong> Rules like “never store secrets” or “the reviewer may read the coder’s memories but not write to them” live in one file instead of being scattered across agents.</li><li><strong>Testability.</strong> I can unit test ranking and budgeting with fake data and no LLM calls at all.</li></ol><p>If you remember one thing from this section: <strong>design memory as a service with a small interface, not as a pile of database calls.</strong> The next step shows that interface.</p><h3>Step 1: Define what a memory is</h3><p>Before writing any storage code, I wrote down the shape of a single memory. Getting this type right saved me from at least two painful migrations later.</p><pre>// src/memory/types.ts<br><br>export type MemoryKind = &quot;fact&quot; | &quot;preference&quot; | &quot;episode&quot; | &quot;procedure&quot;;<br>export interface MemoryRecord {<br>  id: string;              // unique id, e.g. a UUID<br>  namespace: string;       // &quot;agent:coder&quot;, &quot;shared&quot;, &quot;user&quot;<br>  kind: MemoryKind;        // what sort of memory this is<br>  content: string;         // the memory itself, in plain language<br>  embedding: number[];     // vector form of `content`, used for semantic search<br>  importance: number;      // 0..1, how much this matters (set at write time)<br>  createdAt: number;       // epoch milliseconds<br>  lastAccessedAt: number;  // updated every time the memory is recalled<br>  accessCount: number;     // how many times it has been recalled<br>  source?: string;         // where it came from, e.g. &quot;turn:8f2c&quot; or &quot;user&quot;<br>}<br>export interface RecallQuery {<br>  text: string;            // what we are looking for, in natural language<br>  namespaces: string[];    // which namespaces this agent may read<br>  limit?: number;          // max candidates to return (default 20)<br>  kinds?: MemoryKind[];    // optional filter by kind<br>}<br>export interface ScoredMemory {<br>  record: MemoryRecord;<br>  relevance: number;       // cosine similarity to the query<br>  score: number;           // final blended score after ranking<br>}<br>export interface MemoryStore {<br>  insert(record: MemoryRecord): Promise&lt;void&gt;;<br>  update(id: string, patch: Partial&lt;MemoryRecord&gt;): Promise&lt;void&gt;;<br>  delete(id: string): Promise&lt;void&gt;;<br>  listByNamespaces(namespaces: string[], kinds?: MemoryKind[]): Promise&lt;MemoryRecord[]&gt;;<br>}</pre><h4>What each field is for</h4><p>If you are new to this, every field can look arbitrary. Each one earns its place by answering a question the system will ask later.</p><ul><li><strong>namespace</strong> answers <em>&quot;who owns this and who may see it?&quot;</em> Without it, the planner&#39;s half-formed ideas would leak into the coder&#39;s prompt. We will use it in Step 7.</li><li><strong>kind</strong> answers <em>&quot;how should this be treated?&quot;</em> A preference rarely expires; an episode about yesterday&#39;s failed build becomes less useful each week. Different kinds can get different decay rates.</li><li><strong>content</strong> is stored in plain natural language, not JSON. This was deliberate. The content ends up inside a prompt, and models read sentences better than they read serialized objects. It also makes the database readable by a human when you are debugging at midnight.</li><li><strong>embedding</strong> is the numeric fingerprint of the content. It lets us ask &quot;which memories <em>mean</em> something similar to this question?&quot; instead of &quot;which memories contain these exact words?&quot; Step 3 explains it fully.</li><li><strong>importance</strong> answers <em>&quot;how bad would it be to forget this?&quot;</em> &quot;The production database is read-only&quot; deserves 0.95. &quot;The user said thanks&quot; deserves 0.05.</li><li><strong>createdAt, </strong><strong>lastAccessedAt, </strong><strong>accessCount</strong> answer <em>&quot;is this still alive?&quot;</em> A memory that keeps being recalled is clearly useful. One that nobody has touched in months is a candidate for forgetting.</li><li><strong>source</strong> answers <em>&quot;why do we believe this?&quot;</em> When a memory turns out to be wrong, the source tells you which turn or which user message created it.</li></ul><h4>Why MemoryStore is so small</h4><p>Notice that the MemoryStore interface has only four methods and none of them mention similarity search, scoring or tokens. That is on purpose. The store&#39;s only job is to persist records. All the <em>intelligence</em> (ranking, budgeting, deduplication) belongs in the MemoryManager above it.</p><p>This split is the <strong>Repository pattern</strong>. It means the store is easy to replace and the clever logic is easy to test. If you later move to a real vector database, you would add a search(embedding, k) method to the store for speed, but the manager&#39;s public API would not change.</p><h3>Step 2: Make it persistent with SQLite</h3><p>I chose SQLite because a memory layer for a personal harness does not need a server. It needs a single file that survives restarts, supports transactions, and can be inspected with any SQLite viewer. The better-sqlite3 package gives Node a fast, synchronous driver.</p><pre>npm install better-sqlite3<br>npm install -D @types/better-sqlite3</pre><pre>// src/memory/sqliteStore.ts<br>import Database from &quot;better-sqlite3&quot;;<br>import { MemoryKind, MemoryRecord, MemoryStore } from &quot;./types&quot;;<br><br>// Embeddings are arrays of floats. We store them as raw bytes (a BLOB)<br>// because that is ~4x smaller than JSON text and much faster to parse.<br>const toBlob = (v: number[]) =&gt; Buffer.from(new Float32Array(v).buffer);<br>const fromBlob = (b: Buffer) =&gt;<br>  Array.from(new Float32Array(b.buffer, b.byteOffset, b.byteLength / 4));<br>type Row = Omit&lt;MemoryRecord, &quot;embedding&quot;&gt; &amp; { embedding: Buffer };<br>export class SqliteMemoryStore implements MemoryStore {<br>  private db: Database.Database;<br>  constructor(filePath = &quot;./memory.db&quot;) {<br>    this.db = new Database(filePath);<br>    this.db.pragma(&quot;journal_mode = WAL&quot;); // safe concurrent reads while writing<br>    this.db.exec(`<br>      CREATE TABLE IF NOT EXISTS memories (<br>        id             TEXT PRIMARY KEY,<br>        namespace      TEXT NOT NULL,<br>        kind           TEXT NOT NULL,<br>        content        TEXT NOT NULL,<br>        embedding      BLOB NOT NULL,<br>        importance     REAL NOT NULL,<br>        createdAt      INTEGER NOT NULL,<br>        lastAccessedAt INTEGER NOT NULL,<br>        accessCount    INTEGER NOT NULL DEFAULT 0,<br>        source         TEXT<br>      );<br>      CREATE INDEX IF NOT EXISTS idx_mem_ns ON memories(namespace, kind);<br>    `);<br>  }<br>  async insert(r: MemoryRecord): Promise&lt;void&gt; {<br>    this.db<br>      .prepare(<br>        `INSERT INTO memories VALUES<br>         (@id, @namespace, @kind, @content, @embedding, @importance,<br>          @createdAt, @lastAccessedAt, @accessCount, @source)`<br>      )<br>      .run({ ...r, embedding: toBlob(r.embedding), source: r.source ?? null });<br>  }<br>  async update(id: string, patch: Partial&lt;MemoryRecord&gt;): Promise&lt;void&gt; {<br>    const allowed = [&quot;content&quot;, &quot;importance&quot;, &quot;lastAccessedAt&quot;, &quot;accessCount&quot;, &quot;embedding&quot;];<br>    const keys = Object.keys(patch).filter((k) =&gt; allowed.includes(k));<br>    if (keys.length === 0) return;<br>    const sets = keys.map((k) =&gt; `${k} = @${k}`).join(&quot;, &quot;);<br>    const params: Record&lt;string, unknown&gt; = { id, ...patch };<br>    if (patch.embedding) params.embedding = toBlob(patch.embedding);<br>    this.db.prepare(`UPDATE memories SET ${sets} WHERE id = @id`).run(params);<br>  }<br>  async delete(id: string): Promise&lt;void&gt; {<br>    this.db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);<br>  }<br>  async listByNamespaces(namespaces: string[], kinds?: MemoryKind[]): Promise&lt;MemoryRecord[]&gt; {<br>    const nsMarks = namespaces.map(() =&gt; &quot;?&quot;).join(&quot;,&quot;);<br>    let sql = `SELECT * FROM memories WHERE namespace IN (${nsMarks})`;<br>    const args: unknown[] = [...namespaces];<br>    if (kinds?.length) {<br>      sql += ` AND kind IN (${kinds.map(() =&gt; &quot;?&quot;).join(&quot;,&quot;)})`;<br>      args.push(...kinds);<br>    }<br>    const rows = this.db.prepare(sql).all(...args) as Row[];<br>    return rows.map((row) =&gt; ({ ...row, embedding: fromBlob(row.embedding) }));<br>  }<br>}</pre><h4>Walking through the code</h4><p><strong>The table.</strong> Each column maps one-to-one onto a field of MemoryRecord. The only special case is embedding, stored as a BLOB. An embedding with 384 dimensions is 384 floats. As JSON text that is roughly 3 to 4 KB per memory; as a packed Float32Array it is exactly 1,536 bytes and needs no parsing.</p><p><strong>toBlob and </strong><strong>fromBlob.</strong> These two helpers convert between a normal JavaScript array and raw bytes. Float32Array stores each number in exactly 4 bytes, which is why fromBlob divides the byte length by 4. The byteOffset argument matters: Node may hand you a Buffer that is a slice of a larger memory pool, and forgetting the offset gives you garbage numbers. (I lost an evening to that one.)</p><p><strong>WAL mode.</strong> journal_mode = WAL (write-ahead logging) lets readers keep reading while a write is happening. In a harness where an afterTurn hook is writing while the next agent is already recalling, this prevents &quot;database is locked&quot; errors.</p><p><strong>The index.</strong> Every recall filters by namespace first, so an index on (namespace, kind) keeps that filter fast even with tens of thousands of rows.</p><p><strong>Named parameters (</strong><strong>@id, </strong><strong>@content).</strong> Never build SQL by gluing user text into the string. Parameters let SQLite handle escaping, which protects you from SQL injection. Memory content often comes from LLM output, which you should treat as untrusted input.</p><p><strong>The </strong><strong>allowed list in </strong><strong>update.</strong> It looks fussy, but it stops a caller from accidentally changing id or namespace, which would silently move a memory to another agent. Notice that column names are only taken from this fixed list, never from outside input, so the dynamic SET clause stays safe.</p><h4>What about vector search?</h4><p>You may have noticed that listByNamespaces returns <em>every</em> memory in the allowed namespaces, and we will compute similarity in JavaScript. For a personal harness with a few thousand memories, that is perfectly fine: comparing a query against 5,000 vectors of 384 numbers is about two million multiplications, which takes a few milliseconds.</p><p>When you outgrow that, you have options such as the sqlite-vec extension, Postgres with pgvector, or a dedicated vector database. The point of the MemoryStore interface is that you can make that switch later without touching anything above it. <strong>Start with brute force; optimize when you can measure the pain.</strong></p><h3>Step 3: Recall by meaning with embeddings</h3><p>My first recall function used SQL LIKE &#39;%keyword%&#39;. It failed immediately. The agent asked &quot;which installer should I run?&quot; and the memory said &quot;We use pnpm, not npm.&quot; Those two sentences share no important words, yet one is clearly the answer to the other. Keyword search cannot see that. Embeddings can.</p><h4>What an embedding actually is</h4><p>An <strong>embedding model</strong> reads a piece of text and outputs a fixed-length list of numbers, called a <strong>vector</strong>. A small, popular model like all-MiniLM-L6-v2 outputs 384 numbers for any sentence you give it.</p><p>The magic is in how the model was trained: sentences with similar <em>meaning</em> end up with vectors that point in similar <em>directions</em>. You can picture each vector as an arrow in space. “Package manager is pnpm” and “which installer should I run?” point almost the same way. “User prefers dark mode” points somewhere else entirely.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jdmLbycScPpToucdimIsSw.png" /></figure><p><em>A 2-D sketch. Real embeddings have hundreds of dimensions, but the idea of “angle = difference in meaning” holds.</em></p><h4>Measuring “pointing the same way”: cosine similarity</h4><p>To compare two arrows we use <strong>cosine similarity</strong>, the cosine of the angle between them:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/567/1*--YEGIR9jrEVYjaVL8Upfw.png" /></figure><p>In plain words: multiply the two vectors number by number and add it all up (the <em>dot product</em>), then divide by both lengths so that only direction matters, not size. The result runs from -1 to 1:</p><ul><li><strong>close to 1</strong>: same meaning</li><li><strong>around 0</strong>: unrelated</li><li><strong>negative</strong>: pointing in opposite directions (rare with sentence models)</li></ul><h4>The code</h4><p>I run the embedding model locally with Transformers.js, so there are no API keys and no per-call cost. Swapping in a hosted embedding API only changes the Embedder class.</p><pre>npm install @huggingface/transformers</pre><pre>// src/memory/embedder.ts<br>import { pipeline, FeatureExtractionPipeline } from &quot;@huggingface/transformers&quot;;<br><br>export interface Embedder {<br>  embed(text: string): Promise&lt;number[]&gt;;<br>}<br>export class LocalEmbedder implements Embedder {<br>  private extractor?: FeatureExtractionPipeline;<br>  private async load() {<br>    // Downloads the model once (~25 MB) and caches it on disk.<br>    this.extractor ??= await pipeline(&quot;feature-extraction&quot;, &quot;Xenova/all-MiniLM-L6-v2&quot;);<br>    return this.extractor;<br>  }<br>  async embed(text: string): Promise&lt;number[]&gt; {<br>    const extractor = await this.load();<br>    const output = await extractor(text, { pooling: &quot;mean&quot;, normalize: true });<br>    return Array.from(output.data as Float32Array);<br>  }<br>}<br>// src/memory/similarity.ts<br>export function cosineSimilarity(a: number[], b: number[]): number {<br>  if (a.length !== b.length) throw new Error(&quot;Vector length mismatch&quot;);<br>  let dot = 0, normA = 0, normB = 0;<br>  for (let i = 0; i &lt; a.length; i++) {<br>    dot += a[i] * b[i];<br>    normA += a[i] * a[i];<br>    normB += b[i] * b[i];<br>  }<br>  if (normA === 0 || normB === 0) return 0;<br>  return dot / (Math.sqrt(normA) * Math.sqrt(normB));<br>}</pre><p>And the first version of recall inside the MemoryManager:</p><pre>// src/memory/manager.ts (part 1)<br>async recall(q: RecallQuery): Promise&lt;ScoredMemory[]&gt; {<br>  const queryVec = await this.embedder.embed(q.text);<br>  const candidates = await this.store.listByNamespaces(q.namespaces, q.kinds);<br><br>return candidates<br>    .map((record) =&gt; {<br>      const relevance = cosineSimilarity(queryVec, record.embedding);<br>      return { record, relevance, score: relevance };<br>    })<br>    .filter((m) =&gt; m.relevance &gt;= this.minRelevance) // e.g. 0.30<br>    .sort((a, b) =&gt; b.score - a.score)<br>    .slice(0, q.limit ?? 20);<br>}</pre><h4>Understanding the details</h4><p><strong>pooling: &quot;mean&quot;.</strong> The model actually produces one vector per <em>token</em> (roughly per word piece). Mean pooling averages them into a single vector for the whole sentence. Without it you would get a grid of numbers instead of one arrow.</p><p><strong>normalize: true.</strong> This scales every vector to length 1. Once all vectors have length 1, cosine similarity is just the dot product, because the denominators are both 1. I still divide in cosineSimilarity so that the function stays correct if I ever switch to a model that does not normalize.</p><p><strong>this.extractor ??= ....</strong> Loading a model is slow, so we do it once and reuse it. The ??= operator means &quot;assign only if currently undefined&quot;.</p><p><strong>The </strong><strong>minRelevance threshold.</strong> Every memory gets <em>some</em> similarity score, even nonsense ones. Without a floor, a query about installers would still return the dark-mode preference at the bottom of the list, and it would waste prompt space. I settled on 0.30 for this model after logging real scores for a week; you should tune yours the same way, because the right number depends on the embedding model.</p><p><strong>One crucial rule.</strong> Every memory and every query must be embedded with the <strong>same model</strong>. Vectors from two different models live in different “spaces” and comparing them is meaningless. If you ever change models, you must re-embed every stored memory. I now store the model name in a small meta table so the harness can detect a mismatch at startup.</p><h3>Step 4: Rank memories by relevance, recency and importance</h3><p>Similarity alone turned out to be a bad ranking signal. In week two, my coder agent kept pulling up a three-week-old memory saying “tests are flaky, skip the integration suite” because it matched the word-level meaning of every testing question. The flakiness had long been fixed. The memory was relevant but stale.</p><p>The fix is to blend three signals, an idea popularized by the “Generative Agents” research from Stanford in 2023:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/603/1*r1cPTHkl_9t_rm_JSCvDOg.png" /></figure><ul><li><strong>Relevance</strong> is the cosine similarity from Step 3. <em>Does this memory match the question?</em></li><li><strong>Recency</strong> is a number between 0 and 1 that shrinks as time passes since the memory was last used. <em>Is this memory still fresh?</em></li><li><strong>Importance</strong> is the 0 to 1 value stored when the memory was written. <em>How much does this memory matter in general?</em></li><li><strong>The weights</strong> (w) decide how much each signal counts. I use 0.6, 0.2 and 0.2, so relevance still dominates.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hddR0CN3zDTtf-zLa-W2Og.png" /></figure><p><em>Left: how fast recency fades for different half-lives. Right: the “pnpm” fact wins even though the chit-chat memory is fresher, because relevance and importance outweigh recency.</em></p><h4>Exponential decay, explained</h4><p>For recency I use <strong>exponential decay with a half-life</strong>. The idea comes from physics: after one half-life the value is cut in half, after two it is a quarter, and so on.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/482/1*PsKb540LoN3eX78MjWHy0g.png" /></figure><p>Why exponential instead of a straight line? A straight line hits zero at some point and then goes negative, which makes no sense. Exponential decay never reaches zero; an old memory just gets quieter. It can still win if it is extremely relevant, which is exactly the behavior I wanted.</p><p>The half-life should depend on the kind of memory. An episode about today’s debugging session is useful for a day or two. A user preference stays true for months.</p><pre>// src/memory/ranking.ts<br>import { MemoryKind, ScoredMemory } from &quot;./types&quot;;<br><br>const HOUR = 60 * 60 * 1000;<br>export const HALF_LIFE_HOURS: Record&lt;MemoryKind, number&gt; = {<br>  episode: 72,          // 3 days<br>  fact: 24 * 30,        // ~1 month<br>  procedure: 24 * 90,   // ~3 months<br>  preference: 24 * 180, // ~6 months<br>};<br>export interface RankWeights { relevance: number; recency: number; importance: number; }<br>export const DEFAULT_WEIGHTS: RankWeights = { relevance: 0.6, recency: 0.2, importance: 0.2 };<br>export function recencyScore(lastAccessedAt: number, kind: MemoryKind, now = Date.now()): number {<br>  const hours = Math.max(0, (now - lastAccessedAt) / HOUR);<br>  return Math.pow(0.5, hours / HALF_LIFE_HOURS[kind]);<br>}<br>export function rank(<br>  memories: ScoredMemory[],<br>  w: RankWeights = DEFAULT_WEIGHTS,<br>  now = Date.now()<br>): ScoredMemory[] {<br>  return memories<br>    .map((m) =&gt; {<br>      const recency = recencyScore(m.record.lastAccessedAt, m.record.kind, now);<br>      const score =<br>        w.relevance * m.relevance +<br>        w.recency * recency +<br>        w.importance * m.record.importance;<br>      return { ...m, score };<br>    })<br>    .sort((a, b) =&gt; b.score - a.score);<br>}</pre><h4>Why lastAccessedAt and not createdAt?</h4><p>This is subtle and important. If recency were based on when a memory was <em>created</em>, a core fact like “we use pnpm” would fade forever, even though the agent relies on it every day. By measuring time since last <em>use</em>, a memory that keeps proving useful stays fresh. Memories that are never recalled fade naturally.</p><p>To make this work, the MemoryManager must &quot;touch&quot; each memory it actually puts in a prompt:</p><pre>// src/memory/manager.ts (part 2)<br>async touch(used: ScoredMemory[]) {<br>  const now = Date.now();<br>  await Promise.all(<br>    used.map((m) =&gt;<br>      this.store.update(m.record.id, {<br>        lastAccessedAt: now,<br>        accessCount: m.record.accessCount + 1,<br>      })<br>    )<br>  );<br>}</pre><p>This mirrors something real about human memory: recalling a fact strengthens it. Software engineers know the same idea as an LRU (least recently used) cache.</p><h4>Tuning the weights</h4><p>There is no universally correct set of weights. My process was simple: log the top 10 ranked memories for 50 real tasks, mark which ones <em>should</em> have been included, and adjust. Two lessons came out of that:</p><ol><li>If irrelevant-but-recent memories keep appearing, <strong>lower the recency weight</strong>.</li><li>If the agent keeps missing obvious long-standing rules, <strong>raise the importance of those memories at write time</strong> instead of fiddling with weights. Fix the data before you fix the formula.</li></ol><h3>Step 5: Build the context within a token budget</h3><p>Ranking tells us which memories are best. It does not tell us how many to include. My first version included the top 20, always. Then one day an episode memory contained a 3,000-token stack trace, the prompt overflowed, and the request failed. Counting memories was the wrong unit. <strong>The unit is tokens.</strong></p><h4>Give memory a fixed budget</h4><p>I now plan every prompt like a monthly salary: each part gets a fixed allowance, and memory has to live within its own.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wPeuzk5qWkh8qunLUo6aqw.png" /></figure><p><em>Example numbers. Memory gets its own slice. Space for the model’s answer is reserved up front, because a prompt that fills the whole window leaves no room to reply.</em></p><p>Why a fixed slice instead of “whatever space is left over”? Because “left over” changes every turn. Predictable budgets make behavior predictable, and they make costs predictable too.</p><h4>The packing algorithm</h4><p>The approach is <strong>greedy packing</strong>: walk the ranked list from best to worst, and add each memory if it still fits. Skip any memory that does not fit, but keep going, because a smaller memory further down might still fit.</p><pre>// src/memory/context.ts<br>import { ScoredMemory } from &quot;./types&quot;;<br><br>// Fast approximation: ~4 characters per token for English text.<br>// Good enough for budgeting; use a real tokenizer if you need precision.<br>export const estimateTokens = (text: string) =&gt; Math.ceil(text.length / 4);<br>const MAX_TOKENS_PER_MEMORY = 400;<br>function formatMemory(m: ScoredMemory): string {<br>  const date = new Date(m.record.createdAt).toISOString().slice(0, 10);<br>  return `- [${m.record.kind}, ${date}] ${m.record.content}`;<br>}<br>export function buildMemoryContext(ranked: ScoredMemory[], budgetTokens: number) {<br>  const header = &quot;&lt;memory&gt;\nThings you know from earlier work. Prefer the user&#39;s current instructions if they conflict.\n&quot;;<br>  const footer = &quot;&lt;/memory&gt;&quot;;<br>  let used = estimateTokens(header + footer);<br>  const lines: string[] = [];<br>  const included: ScoredMemory[] = [];<br>  for (const m of ranked) {<br>    const line = formatMemory(m);<br>    const cost = estimateTokens(line) + 1; // +1 for the newline<br>    if (cost &gt; MAX_TOKENS_PER_MEMORY) continue; // oversized: summarize it later (Step 8)<br>    if (used + cost &gt; budgetTokens) continue;   // doesn&#39;t fit, but a smaller one might<br>    lines.push(line);<br>    included.push(m);<br>    used += cost;<br>  }<br>  if (included.length === 0) return { text: &quot;&quot;, included, tokens: 0 };<br>  return { text: header + lines.join(&quot;\n&quot;) + &quot;\n&quot; + footer, included, tokens: used };<br>}</pre><h4>Line by line</h4><p><strong>estimateTokens.</strong> Tokens are the chunks a model actually reads; for English, one token is roughly four characters. This estimate is off by 10 to 20 percent, which is fine for budgeting as long as you leave headroom. If you need exact counts, use the tokenizer that matches your model.</p><p><strong>MAX_TOKENS_PER_MEMORY.</strong> This guard is the fix for my stack-trace disaster. No single memory is allowed to eat the budget. Oversized memories get skipped now and condensed later by the consolidation job.</p><p><strong>continue instead of </strong><strong>break.</strong> If memory number 3 is too big, break would stop and throw away memories 4 to 20, even if they are small and useful. continue skips just that one. This is a simple form of the classic <em>knapsack</em> problem: greedy is not perfectly optimal, but it is fast, predictable and good enough.</p><p><strong>formatMemory.</strong> Each memory becomes one bullet with its kind and date. The date lets the model reason about staleness itself (&quot;this note is from March; the user may have changed their mind&quot;). The kind tells it whether it is reading a rule or a story.</p><p><strong>The </strong><strong>&lt;memory&gt; tags and the header sentence.</strong> Wrapping memories in clear tags helps the model tell recalled background apart from the user&#39;s live instructions. The sentence &quot;prefer the user&#39;s current instructions if they conflict&quot; settles a real problem: without it, an old memory could override something the user just said.</p><p><strong>Returning </strong><strong>included.</strong> The manager needs to know which memories were actually used so it can touch() them (Step 4). Only memories that make it into the prompt get their recency refreshed.</p><h4>Where to put the block in the prompt</h4><p>Research on long contexts has repeatedly found that models pay more attention to the beginning and the end of a prompt than to the middle, an effect often called “lost in the middle”. I place the memory block just before the user’s task, after the static system prompt and tool definitions. That keeps it close to the question it is meant to help answer.</p><h3>Step 6: Write memories back with lifecycle hooks</h3><p>Reading memory is only half the loop. Something has to decide what is worth remembering after each turn. I tried two wrong approaches before finding one that worked:</p><ol><li><strong>Store every message.</strong> The database filled with “ok”, “thanks” and half-finished thoughts. Recall quality collapsed because noise outnumbered signal.</li><li><strong>Give the agent a </strong><strong>save_memory tool.</strong> Better, but the agent forgot to call it about half the time, and when it did call it, it interrupted the task it was doing.</li></ol><p>The approach that stuck is a <strong>lifecycle hook</strong>: a function the harness calls automatically after every turn, separate from the agent’s main work.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*P_jEypZyOPZZJgyyDAdYbw.png" /></figure><p><em>The afterTurn pipeline. It runs after the user already has their answer, so it adds no visible latency.</em></p><h4>A tiny hook system</h4><p>Hooks are just named events with a list of listeners. Here is the whole implementation:</p><pre>// src/harness/hooks.ts<br>export interface TurnEvent {<br>  turnId: string;<br>  agentId: string;        // e.g. &quot;coder&quot;<br>  userMessage: string;<br>  agentReply: string;<br>  toolCalls: { name: string; summary: string }[];<br>}<br><br>type HookMap = {<br>  afterTurn: (e: TurnEvent) =&gt; Promise&lt;void&gt;;<br>  onSessionEnd: (sessionId: string) =&gt; Promise&lt;void&gt;;<br>};<br>export class HookBus {<br>  private listeners: { [K in keyof HookMap]: HookMap[K][] } = { afterTurn: [], onSessionEnd: [] };<br>  on&lt;K extends keyof HookMap&gt;(name: K, fn: HookMap[K]) {<br>    this.listeners[name].push(fn);<br>  }<br>  // Fire-and-forget: a failing hook must never crash the agent&#39;s turn.<br>  emit&lt;K extends keyof HookMap&gt;(name: K, ...args: Parameters&lt;HookMap[K]&gt;) {<br>    for (const fn of this.listeners[name]) {<br>      (fn as (...a: unknown[]) =&gt; Promise&lt;void&gt;)(...args).catch((err) =&gt;<br>        console.error(`[hook:${name}] failed`, err)<br>      );<br>    }<br>  }<br>}</pre><p><strong>Why fire-and-forget?</strong> The user is waiting for the agent’s answer, not for the memory write. By not awaiting the hook, the reply goes out immediately and memory extraction happens in the background. The .catch is essential: an unhandled promise rejection in Node can crash the process, and a memory bug should never take down the agent.</p><h4>Extracting durable facts with an LLM</h4><p>The extraction step asks a small, cheap model to read the turn and pull out only things worth remembering. I treat the model as a function behind a minimal interface so any provider works.</p><pre>// src/memory/extract.ts<br>import { MemoryKind } from &quot;./types&quot;;<br>import { TurnEvent } from &quot;../harness/hooks&quot;;<br><br>export interface LLM { complete(prompt: string): Promise&lt;string&gt;; }<br>export interface Candidate { kind: MemoryKind; content: string; importance: number; }<br>const EXTRACT_PROMPT = (e: TurnEvent) =&gt; `<br>You maintain long-term memory for a software agent.<br>Read this turn and list facts worth remembering in FUTURE sessions.<br>Rules:<br>- Only durable information: decisions, preferences, project conventions, lessons from failures.<br>- Skip greetings, thanks, and anything only relevant to this exact moment.<br>- Write each item as one self-contained sentence that makes sense with no other context.<br>- Never include passwords, API keys, tokens or personal data.<br>- importance: 0.9+ for hard rules, ~0.5 for useful context, &lt;0.3 for trivia.<br>Return ONLY a JSON array like:<br>[{&quot;kind&quot;:&quot;fact|preference|episode|procedure&quot;,&quot;content&quot;:&quot;...&quot;,&quot;importance&quot;:0.0}]<br>Return [] if nothing is worth keeping.<br>USER: ${e.userMessage}<br>AGENT: ${e.agentReply}<br>TOOLS: ${e.toolCalls.map((t) =&gt; `${t.name}: ${t.summary}`).join(&quot;; &quot;)}<br>`;<br>export async function extractCandidates(llm: LLM, e: TurnEvent): Promise&lt;Candidate[]&gt; {<br>  const raw = await llm.complete(EXTRACT_PROMPT(e));<br>  try {<br>    const json = raw.slice(raw.indexOf(&quot;[&quot;), raw.lastIndexOf(&quot;]&quot;) + 1);<br>    const parsed = JSON.parse(json) as Candidate[];<br>    return parsed.filter(<br>      (c) =&gt; typeof c.content === &quot;string&quot; &amp;&amp; c.content.length &gt; 0 &amp;&amp;<br>             typeof c.importance === &quot;number&quot;<br>    );<br>  } catch {<br>    return []; // a malformed answer means &quot;remember nothing&quot;, never a crash<br>  }<br>}</pre><p>The most important rule in that prompt is <strong>“one self-contained sentence”</strong>. A memory like “use the second option” is useless three weeks later; nobody remembers what the options were. “Retry logic in fetchTool uses exponential backoff, max 3 attempts” makes sense on its own.</p><p>The JSON parsing is defensive on purpose. Models sometimes wrap JSON in prose or code fences, so I slice from the first [ to the last ]. If parsing still fails, the hook stores nothing. Losing one memory is far better than storing garbage.</p><h4>Filter, dedupe and store</h4><p>The last stage lives in the MemoryManager. It filters, checks for near-duplicates, and either inserts or reinforces.</p><pre>// src/memory/manager.ts (part 3)<br>import { randomUUID } from &quot;node:crypto&quot;;<br><br>const SECRET_PATTERNS = [<br>  /sk-[A-Za-z0-9_-]{20,}/,              // common API key shape<br>  /AKIA[0-9A-Z]{16}/,                    // AWS access key id<br>  /-----BEGIN [A-Z ]*PRIVATE KEY-----/,<br>  /password\s*[:=]/i,<br>];<br>const MIN_IMPORTANCE = 0.3;<br>const DUPLICATE_THRESHOLD = 0.92;<br>async remember(c: Candidate, namespace: string, source: string): Promise&lt;&quot;stored&quot; | &quot;merged&quot; | &quot;skipped&quot;&gt; {<br>  if (c.importance &lt; MIN_IMPORTANCE) return &quot;skipped&quot;;<br>  if (SECRET_PATTERNS.some((p) =&gt; p.test(c.content))) return &quot;skipped&quot;;<br>  const embedding = await this.embedder.embed(c.content);<br>  const existing = await this.store.listByNamespaces([namespace], [c.kind]);<br>  let best: { id: string; sim: number; importance: number; accessCount: number } | null = null;<br>  for (const r of existing) {<br>    const sim = cosineSimilarity(embedding, r.embedding);<br>    if (!best || sim &gt; best.sim) best = { id: r.id, sim, importance: r.importance, accessCount: r.accessCount };<br>  }<br>  const now = Date.now();<br>  if (best &amp;&amp; best.sim &gt;= DUPLICATE_THRESHOLD) {<br>    // Same fact seen again: reinforce instead of duplicating.<br>    await this.store.update(best.id, {<br>      content: c.content, // newer wording wins, it may carry a correction<br>      embedding,<br>      importance: Math.max(best.importance, c.importance),<br>      lastAccessedAt: now,<br>      accessCount: best.accessCount + 1,<br>    });<br>    return &quot;merged&quot;;<br>  }<br>  await this.store.insert({<br>    id: randomUUID(), namespace, kind: c.kind, content: c.content, embedding,<br>    importance: c.importance, createdAt: now, lastAccessedAt: now, accessCount: 0, source,<br>  });<br>  return &quot;stored&quot;;<br>}</pre><p>And the wiring that connects it all:</p><pre>// src/harness/setup.ts<br>hooks.on(&quot;afterTurn&quot;, async (e) =&gt; {<br>  const candidates = await extractCandidates(cheapLLM, e);<br>  for (const c of candidates) {<br>    // Preferences and procedures are team-wide; facts and episodes stay with the agent.<br>    const ns = c.kind === &quot;preference&quot; || c.kind === &quot;procedure&quot; ? &quot;shared&quot; : `agent:${e.agentId}`;<br>    await memory.remember(c, ns, `turn:${e.turnId}`);<br>  }<br>});</pre><h4>Why each safeguard exists</h4><p><strong>The importance floor (0.3).</strong> The extractor sometimes keeps trivia despite instructions. A numeric floor is a cheap second line of defense.</p><p><strong>The secret patterns.</strong> LLMs see API keys in tool outputs and config files all the time. A memory store that persists secrets turns one leaked log into a permanent leak in every future prompt. Regexes will not catch everything, but they catch the most common shapes. Defense in depth: the prompt says “never”, the code double-checks.</p><p><strong>Deduplication at 0.92.</strong> Without it, the same fact gets stored again every time it comes up in conversation: “we use pnpm”, “project uses pnpm”, “pnpm is the package manager”. Duplicates waste the token budget and crowd out other memories. Any two sentences above roughly 0.92 cosine similarity are, for this model, almost always saying the same thing. I found that number by printing pairs and reading them, not by theory.</p><p><strong>“Newer wording wins.”</strong> When a duplicate arrives, I overwrite the content with the new version. This is how corrections propagate. If the old memory said “deploy on Fridays” and the user now says “we deploy on Thursdays”, the two are similar enough to merge, and the newest version is the one that survives. Contradictions that are <em>not</em> similar enough to merge are handled by the consolidation job in Step 8.</p><h3>Step 7: Share memory across agents with namespaces</h3><p>In a multi-agent harness, “who can see what” is a design decision, not an afterthought. Namespaces are how I made that decision explicit.</p><p>A <strong>namespace</strong> is a label on every memory, like a folder name. Each agent writes to its own private namespace by default, and a small policy table decides which other namespaces it may read.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/808/1*GuG5T7buMGcuRhmOacylIA.png" /></figure><p>The policy in code:</p><pre>// src/memory/policy.ts<br>export interface NamespacePolicy { read: string[]; write: string[]; }<br><br>export const POLICY: Record&lt;string, NamespacePolicy&gt; = {<br>  planner:  { read: [&quot;agent:planner&quot;, &quot;shared&quot;, &quot;user&quot;],                 write: [&quot;agent:planner&quot;, &quot;shared&quot;] },<br>  coder:    { read: [&quot;agent:coder&quot;, &quot;shared&quot;, &quot;user&quot;],                   write: [&quot;agent:coder&quot;, &quot;shared&quot;] },<br>  reviewer: { read: [&quot;agent:reviewer&quot;, &quot;agent:coder&quot;, &quot;shared&quot;, &quot;user&quot;], write: [&quot;agent:reviewer&quot;] },<br>};<br>export function readableNamespaces(agentId: string): string[] {<br>  const p = POLICY[agentId];<br>  if (!p) throw new Error(`No memory policy for agent &quot;${agentId}&quot;`);<br>  return p.read;<br>}<br>export function assertCanWrite(agentId: string, namespace: string): void {<br>  if (!POLICY[agentId]?.write.includes(namespace)) {<br>    throw new Error(`Agent &quot;${agentId}&quot; may not write to &quot;${namespace}&quot;`);<br>  }<br>}</pre><h4>The reasoning behind each rule</h4><p><strong>Private by default.</strong> Each agent has its own working style and its own scratch knowledge. The coder’s note “the date utils file has a quirky timezone helper” is useful to the coder and noise to the planner. Keeping namespaces private by default keeps each agent’s context focused.</p><p><strong>A </strong><strong>shared namespace for team knowledge.</strong> Preferences and procedures apply to everyone: &quot;use pnpm&quot;, &quot;all new code needs tests&quot;. Those go to shared, so a fact learned by any agent benefits the whole team. This is where the multi-agent setup really pays off.</p><p><strong>A read-only </strong><strong>user namespace.</strong> Facts the user tells the harness directly (&quot;I&#39;m on Node 22&quot;, &quot;never touch the legacy billing folder&quot;) get the highest trust. Only the user, through an explicit command, writes there. Agents can read it but cannot change it, so an agent cannot &quot;remember&quot; its way around a user rule.</p><p><strong>The reviewer reads the coder’s memory.</strong> This was a deliberate asymmetry. A good reviewer needs to know <em>why</em> the coder made its choices (“used polling because the API has no webhooks”). But the reviewer should not write into the coder’s namespace, or the two agents could start rewriting each other’s knowledge. Read access across a boundary is cheap; write access across a boundary needs a strong reason.</p><p><strong>Fail loudly.</strong> readableNamespaces throws when an agent has no policy. Silently defaulting to &quot;read everything&quot; is exactly the kind of bug that leaks context and is hard to notice.</p><h4>Using it in the manager</h4><p>The agent never chooses its own namespaces. The manager looks them up:</p><pre>// inside the agent runner<br>const namespaces = readableNamespaces(agent.id);<br>const candidates = await memory.recall({ text: task, namespaces, limit: 40 });</pre><p>This is the <strong>principle of least privilege</strong>, the same idea behind file permissions and database roles: give each component only the access it needs. As your swarm grows, you will be glad that access rules are one table rather than if-statements spread across ten agents.</p><h3>Step 8: Teach the system to forget</h3><p>A memory system that only adds will eventually drown in its own history. After a month, my store had about 2,000 records, and recall quality was noticeably worse than in week one. The good memories were still there; they were simply outnumbered.</p><p>Forgetting is not a bug to minimize. It is a feature you have to build. I use two mechanisms, both running in a scheduled maintenance job rather than during user turns.</p><h4>Mechanism 1: Prune what has faded</h4><p>Every memory has a <strong>strength</strong> that combines how recently it was used, how important it is and how often it has been recalled. When strength falls below a threshold, the memory is deleted.</p><pre>// src/memory/maintenance.ts (part 1)<br>import { recencyScore } from &quot;./ranking&quot;;<br>import { MemoryRecord } from &quot;./types&quot;;<br><br>const PRUNE_THRESHOLD = 0.05;<br>export function strength(r: MemoryRecord, now = Date.now()): number {<br>  const recency = recencyScore(r.lastAccessedAt, r.kind, now);<br>  const usageBoost = Math.min(1, Math.log1p(r.accessCount) / Math.log(20)); // 0..1, saturates near 20 uses<br>  return recency * (0.5 + 0.5 * r.importance) * (0.5 + 0.5 * usageBoost);<br>}<br>export async function prune(store: MemoryStore, namespaces: string[]) {<br>  const all = await store.listByNamespaces(namespaces);<br>  const doomed = all.filter((r) =&gt; r.importance &lt; 0.9 &amp;&amp; strength(r) &lt; PRUNE_THRESHOLD);<br>  for (const r of doomed) await store.delete(r.id);<br>  return doomed.length;<br>}</pre><p><strong>How the formula behaves.</strong> Each factor is between 0 and 1, and multiplying them means <em>any</em> strong signal can keep a memory alive. A memory that has not been used in months but has importance 0.8 keeps a healthy importance factor. A memory used 20 times maxes out the usage factor. Only memories that are old, unimportant <em>and</em> rarely used drop below 0.05.</p><p><strong>Math.log1p for usage.</strong> The logarithm gives diminishing returns: the jump from 0 to 5 uses matters a lot, the jump from 50 to 55 barely matters. Without it, one memory recalled hundreds of times would become immortal and unbalance everything.</p><p><strong>The </strong><strong>importance &lt; 0.9 guard.</strong> Hard rules (&quot;never run migrations against production&quot;) are never pruned automatically, however long they go unused. Some knowledge is valuable precisely because it is rarely needed.</p><h4>Mechanism 2: Consolidate many episodes into one lesson</h4><p>Episodes pile up fast. Over two weeks my coder stored eleven separate episodes that were all versions of the same lesson: “tests failed because the mock server wasn’t started”. Eleven memories, one idea.</p><p>Consolidation finds clusters of similar episodes and asks an LLM to summarize each cluster into a single durable fact. This is the closest thing my harness has to what sleep does for human memory: turning many specific experiences into one general piece of knowledge.</p><pre>// src/memory/maintenance.ts (part 2)<br>const CLUSTER_THRESHOLD = 0.80;<br>const MIN_CLUSTER_SIZE = 3;<br><br>function clusterBySimilarity(records: MemoryRecord[]): MemoryRecord[][] {<br>  const clusters: MemoryRecord[][] = [];<br>  const used = new Set&lt;string&gt;();<br>  for (const seed of records) {<br>    if (used.has(seed.id)) continue;<br>    const group = records.filter(<br>      (r) =&gt; !used.has(r.id) &amp;&amp; cosineSimilarity(seed.embedding, r.embedding) &gt;= CLUSTER_THRESHOLD<br>    );<br>    group.forEach((r) =&gt; used.add(r.id));<br>    if (group.length &gt;= MIN_CLUSTER_SIZE) clusters.push(group);<br>  }<br>  return clusters;<br>}<br>export async function consolidate(memory: MemoryManager, store: MemoryStore, llm: LLM, namespace: string) {<br>  const episodes = await store.listByNamespaces([namespace], [&quot;episode&quot;]);<br>  for (const cluster of clusterBySimilarity(episodes)) {<br>    const lesson = await llm.complete(<br>      `These are related notes from past work:\n` +<br>      cluster.map((r) =&gt; `- ${r.content}`).join(&quot;\n&quot;) +<br>      `\n\nWrite ONE self-contained sentence capturing the durable lesson. ` +<br>      `If the notes contradict each other, prefer the most recent. Reply with the sentence only.`<br>    );<br>    const importance = Math.min(1, Math.max(...cluster.map((r) =&gt; r.importance)) + 0.1);<br>    await memory.remember({ kind: &quot;fact&quot;, content: lesson.trim(), importance }, namespace, &quot;consolidation&quot;);<br>    for (const r of cluster) await store.delete(r.id);<br>  }<br>}</pre><h4>Understanding consolidation</h4><p><strong>The clustering is deliberately simple.</strong> It picks a seed memory and groups everything within 0.80 similarity of it. That threshold is lower than the 0.92 used for deduplication on purpose: duplicates say <em>the same thing</em>, cluster members are <em>about the same thing</em>. Real clustering algorithms (k-means, HDBSCAN) exist, but this greedy version is easy to read and debug, and it works well for a few hundred episodes.</p><p><strong>MIN_CLUSTER_SIZE = 3.</strong> One or two similar episodes might be coincidence. Three or more suggests a pattern worth turning into a rule.</p><p><strong>Episodes become facts.</strong> The output has kind fact, which has a much longer half-life than episode. The specific stories fade; the lesson stays.</p><p><strong>Importance gets a small boost.</strong> A lesson that showed up three or more times is, by definition, recurring. Giving it slightly more importance than its strongest source reflects that.</p><p><strong>Contradictions resolve toward recency.</strong> The prompt tells the summarizer to prefer the most recent note. Combined with the “newer wording wins” rule from Step 6, this keeps the store from holding two opposite beliefs at once.</p><p><strong>Order matters: write before delete.</strong> The new consolidated memory is saved <em>before</em> the old episodes are removed. If the process crashes halfway, you end up with a duplicate, which the next run will merge. The opposite order could lose knowledge entirely.</p><p>I run prune and consolidate once a night through a scheduled job, plus at onSessionEnd for the namespaces touched during that session.</p><h3>Putting it together: one full turn</h3><p>We now have every piece. Here is how a single task flows through the harness, from the moment it arrives until its lessons are stored.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WxZOefsbXYA9DErEDEi5Rw.png" /></figure><p><em>The read path (blue) runs before the model is called. The write path (purple) runs after, in the background.</em></p><p>And here is the runner that ties it all together:</p><pre>// src/harness/runTurn.ts<br>import { randomUUID } from &quot;node:crypto&quot;;<br><br>const MEMORY_BUDGET_TOKENS = 2000;<br>export async function runTurn(task: string, sessionHistory: Message[]): Promise&lt;string&gt; {<br>  const turnId = randomUUID();<br>  // 1-2. Route the task to the right agent<br>  const agent = await router.pick(task);<br>  // 3. Recall candidates from the namespaces this agent may read, then rank them<br>  const candidates = await memory.recall({<br>    text: task,<br>    namespaces: readableNamespaces(agent.id),<br>    limit: 40,<br>  });<br>  const ranked = rank(candidates);<br>  // 4. Pack the best memories into the memory budget<br>  const memoryCtx = buildMemoryContext(ranked, MEMORY_BUDGET_TOKENS);<br>  await memory.touch(memoryCtx.included); // reinforce what we actually used<br>  // 5. Call the model with system prompt + memory + recent turns + task<br>  const reply = await agent.run({<br>    system: agent.systemPrompt,<br>    memory: memoryCtx.text,<br>    history: trimHistory(sessionHistory, 2500),<br>    task,<br>  });<br>  // 6. Fire the write path in the background; the user does not wait for it<br>  hooks.emit(&quot;afterTurn&quot;, {<br>    turnId,<br>    agentId: agent.id,<br>    userMessage: task,<br>    agentReply: reply.text,<br>    toolCalls: reply.toolCalls,<br>  });<br>  return reply.text;<br>}</pre><h4>Reading the runner as a story</h4><p>Read the function from top to bottom and it tells the whole story of this post:</p><ol><li><strong>Route.</strong> Pick the specialist for the job.</li><li><strong>Recall.</strong> Embed the task and find memories that mean something similar, but only from namespaces this agent is allowed to read.</li><li><strong>Rank.</strong> Blend relevance, recency and importance so that fresh, important, on-topic memories rise to the top.</li><li><strong>Budget.</strong> Pack the winners into a fixed token allowance, skipping anything oversized, and refresh the memories we used.</li><li><strong>Think.</strong> The model sees the system prompt, a compact &lt;memory&gt; block, the recent conversation and the task.</li><li><strong>Remember.</strong> After the answer is sent, a hook extracts durable lessons, filters them, deduplicates them and stores them.</li><li><strong>Maintain.</strong> Separately, on a schedule, weak memories fade away and repeated episodes become lasting facts.</li></ol><p>Notice what the agent itself does <em>not</em> know about: SQLite, vectors, half-lives, deduplication thresholds. To the agent, memory is simply a paragraph of helpful background that appears in its prompt. That is the sign of a good abstraction. All the complexity is real, but it lives in one place.</p><h4>A tip for debugging</h4><p>Log memoryCtx.included with each memory&#39;s relevance, recency, importance and final score on every turn. When the agent does something strange, the first question is always &quot;what did it remember?&quot; Having that answer in a log line turned hour-long debugging sessions into five-minute ones.</p><h3>Lessons learned the hard way</h3><p>Building this by hand taught me more than any library’s documentation could have. These are the mistakes I would warn my past self about.</p><p><strong>1. Memory quality is decided at write time, not read time.</strong> I spent days tuning ranking weights before realizing the real problem was what I was storing. Garbage in the store cannot be ranked into gold. A strict extraction prompt and an importance floor did more for recall quality than any formula change.</p><p><strong>2. Store sentences, not fragments.</strong> Every memory must make sense when read alone, weeks later, by an agent with no context. “Use the second approach” is worthless. “Retries use exponential backoff starting at 200 ms” is gold. If you change only one thing in your own system, change this.</p><p><strong>3. Count tokens, never items.</strong> “Top 10 memories” is a bug waiting to happen. One oversized memory can blow your context window. Budget in tokens and cap the size of each individual memory.</p><p><strong>4. Recency must mean last <em>used</em>, not last <em>created</em>.</strong> Otherwise your most important long-standing facts slowly fade out of every prompt, precisely because they have been true for a long time.</p><p><strong>5. Without deduplication, the loudest fact wins.</strong> A fact mentioned in ten conversations gets ten copies, which then occupy ten slots in the prompt and push out everything else. Merge near-duplicates on write.</p><p><strong>6. Memory needs a way to be wrong.</strong> Old memories will contradict new reality. Tell the model in the prompt that current instructions win, let newer wording overwrite older wording on merge, and resolve clusters toward the most recent note.</p><p><strong>7. Never persist secrets.</strong> It is very easy for an API key in a tool output to become a “fact” that is then injected into every future prompt. Filter in the prompt and again in code.</p><p><strong>8. Make memory observable.</strong> Log what was recalled, its scores, and what was written on every turn. When an agent misbehaves, “what did it remember?” is almost always the first question, and you want the answer in seconds.</p><p><strong>9. Thresholds are empirical.</strong> The numbers in this post (0.30 relevance floor, 0.92 duplicate threshold, 0.80 cluster threshold) were found by printing real scores and reading them. They depend on your embedding model. Copy the method, not the numbers.</p><p><strong>10. Start brute force.</strong> A for loop over a few thousand vectors is fast enough. I almost introduced a vector database on day one, and I would have spent that week on infrastructure instead of learning how memory actually behaves.</p><h4>Hand-rolled vs. using a framework</h4><p>Would I recommend everyone build their own memory layer for production? No. Mature frameworks handle edge cases, scale and concurrency that this design ignores. But I would recommend building it <em>once</em>. After this project, when I read the documentation of any memory or RAG framework, I can map every option to a concept I have implemented myself: namespaces, half-lives, consolidation, token budgets. That understanding is what makes you good at choosing and configuring tools, rather than just installing them.</p><h3>Conclusion and what’s next</h3><p>An agent’s memory is not a feature of the model. It is a system you build around the model, and it comes down to answering four questions well:</p><ul><li><strong>What is worth remembering?</strong> Extract durable, self-contained facts after each turn; filter noise and secrets.</li><li><strong>What is relevant right now?</strong> Embed, compare by meaning, and rank by relevance, recency and importance.</li><li><strong>How much can we afford to show?</strong> Pack the best memories into a fixed token budget.</li><li><strong>What should we let go?</strong> Prune what has faded and consolidate repeated experiences into lasting lessons.</li></ul><p>Wiring each of those by hand turned “memory” from a black box into a set of small, understandable pieces: a record type, a store, an embedder, a ranking function, a packer, a hook and a maintenance job. None of them is more than a hundred lines of TypeScript. Together, they turned my team of forgetful contractors into something that actually learns from working on a project.</p><p>The next pieces I am building in the harness:</p><ul><li><strong>Hybrid search.</strong> Combining embeddings with keyword search (BM25) so exact identifiers like function names and error codes are recalled reliably.</li><li><strong>A memory inspector.</strong> A small UI to browse, edit and delete memories by namespace, because the user should always be able to see and correct what the agents believe.</li><li><strong>Evaluation.</strong> A fixed set of tasks with known “correct” memories, so that every change to thresholds or prompts can be measured instead of guessed.</li></ul><p>If you are building your own agent harness, I hope this saves you a few of the evenings it cost me. Start with the MemoryRecord type, keep the store boring, and spend your energy on what gets written.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d47ec22f33f7" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[ESM-Only Libraries Are Here: A Migration Playbook for CommonJS-Heavy Node Codebases]]></title>
            <link>https://medium.com/@vasanthank29/esm-only-libraries-are-here-a-migration-playbook-for-commonjs-heavy-node-codebases-d165adf2fa57?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/d165adf2fa57</guid>
            <category><![CDATA[nodejs]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[software-engineering]]></category>
            <category><![CDATA[modules]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Wed, 28 Oct 2026 13:01:03 GMT</pubDate>
            <atom:updated>2026-10-28T13:01:03.082Z</atom:updated>
            <content:encoded><![CDATA[<h4>The error that started it all</h4><p>If you have maintained a Node.js service for more than a couple of years, you have probably met this message after a routine npm update:</p><pre>Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/chalk/source/index.js<br>from /app/src/logger.js not supported.<br>Instead change the require of index.js to a dynamic import() which is available in all CommonJS modules.</pre><p>You did nothing wrong. Your code still says const chalk = require(&#39;chalk&#39;), exactly as it did yesterday. What changed is the library: its new major version stopped shipping CommonJS and now ships <strong>only</strong> ECMAScript Modules (ESM). Packages like chalk v5, node-fetch v3, got v12+, nanoid v4+, p-queue v7+, execa, and recent major versions of uuid all made this move, and the list grows every month.</p><p>For years, the honest advice was “pin the old version and wait.” That advice has now expired. Two things changed in 2025–2026:</p><ol><li><strong>Node.js can </strong><strong>require() ES modules.</strong> The feature known as require(esm) is enabled by default on every supported Node.js line and is now marked stable. A CommonJS file can load most ESM-only packages with a plain require().</li><li><strong>Old versions are out of support.</strong> Pinning chalk@4 forever means pinning a package that will never get another security fix. The &quot;just pin it&quot; strategy now carries real risk.</li></ol><p>This article is a playbook. It explains the theory from scratch, so a beginner can follow it, and then gives a step-by-step plan, with code, for teams that have hundreds of thousands of lines of CommonJS and cannot rewrite everything in one sprint.</p><p><strong>What you will learn:</strong></p><ul><li>What CommonJS and ESM actually are, and why they behave differently under the hood</li><li>Why ERR_REQUIRE_ESM existed, and how require(esm) removes most of it</li><li>The three remaining traps: top-level await, the .default wrapper, and tooling</li><li>A four-phase migration playbook: audit, choose a strategy, migrate incrementally, harden tooling</li><li>How to publish your own internal libraries so they work for every consumer</li></ul><p>All the diagrams in this post were created specifically for it. All code examples target Node.js 22.12 or newer.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GFHxxOnse_FjfCbSNICfdQ.png" /></figure><h3>Part 1: CommonJS and ESM, explained from zero</h3><p>A <strong>module</strong> is simply a file that keeps its variables private and chooses what to share with other files. JavaScript did not have modules built into the language for most of its life, so Node.js invented its own system in 2009. That system is <strong>CommonJS (CJS)</strong>. In 2015, the language itself gained an official module system: <strong>ECMAScript Modules (ESM)</strong>. Node.js has supported both since version 12, and that is the root of every problem in this article.</p><h4>CommonJS: the original Node.js way</h4><p>Here is a tiny CommonJS module and a file that uses it:</p><pre>// math.js  (CommonJS)<br>const PI = 3.14159;                 // private: nobody outside can see this<br><br>function area(r) {<br>  return PI * r * r;<br>}<br>module.exports = { area };          // public: this object is what require() returns</pre><pre>// app.js  (CommonJS)<br>const { area } = require(&#39;./math&#39;); // runs math.js right now, returns module.exports<br>console.log(area(2));               // 12.56636</pre><p>Three things are happening behind the scenes, and they explain almost every migration problem later on:</p><ol><li><strong>require() is an ordinary function call.</strong> When Node reaches line 1 of app.js, it pauses, finds math.js on disk, reads it, runs it from top to bottom, and hands back whatever ended up in module.exports. Only then does line 2 run. This is <strong>synchronous</strong> loading.</li><li><strong>Node wraps every CJS file in a hidden function.</strong> Before running your code, Node places it inside function (exports, require, module, __filename, __dirname) { ... }. That is why require, module, __dirname and __filename seem to exist &quot;for free&quot; in CommonJS. They are not globals. They are parameters of that invisible wrapper.</li><li><strong>Exports are a snapshot of an object.</strong> module.exports is a plain object. If math.js later changes a variable, code that already destructured { area } keeps the old value.</li></ol><p>Because require() is just a function, you can call it anywhere: inside an if, inside a function, with a computed string. That flexibility is convenient, but it also means tools cannot know what a file depends on until the code actually runs.</p><h4>ES Modules: the language standard</h4><p>The same example written as ESM:</p><pre>// math.mjs  (ESM)<br>const PI = 3.14159;<br><br>export function area(r) {           // named export<br>  return PI * r * r;<br>}<br>export default { area };            // optional default export</pre><pre>// app.mjs  (ESM)<br>import { area } from &#39;./math.mjs&#39;;  // a declaration, not a function call<br>console.log(area(2));</pre><p>This looks like a syntax change, but the engine treats it completely differently:</p><ul><li><strong>import and </strong><strong>export are declarations.</strong> They must sit at the top level of the file, and the module name must be a fixed string. The engine can read every dependency without executing a single line.</li><li><strong>Bindings are live.</strong> area in app.mjs is not a copy. It is a read-only window onto the variable inside math.mjs. If the exporting module updates it, importers see the new value.</li><li><strong>Strict mode is always on</strong>, and there is no hidden wrapper. That is why __dirname, require and module do not exist inside ESM.</li><li><strong>File paths must be complete.</strong> import &#39;./math&#39; fails in Node. You must write ./math.js or ./math.mjs. ESM follows the same rules as browsers, which never guess file extensions.</li></ul><h4>The real difference: how loading happens</h4><p>The diagram below is the most important picture in this article.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9Rldz_WJ-4P7uaEJSMqWbg.png" /></figure><p><strong>CommonJS loads and runs at the same time.</strong> Each require() reads one file and executes it immediately. When that file calls require() itself, the process repeats, nested inside the first call. It is one long, synchronous chain.</p><p><strong>ESM works in three separate phases across the whole dependency graph:</strong></p><ol><li><strong>Construction.</strong> Node finds every file reachable from the entry point, downloads or reads it, and parses it. It records every import and export, but runs nothing.</li><li><strong>Instantiation.</strong> Node connects each import to the matching export as a live binding. If you import a name that does not exist, the error appears here, before any of your code runs.</li><li><strong>Evaluation.</strong> Node finally executes each module body, starting from the deepest dependency and moving up.</li></ol><p>Phase 3 is the key. ESM allows <strong>top-level </strong><strong>await</strong>: an await written directly in the module body, outside any function. When a module does that, its evaluation pauses until the promise settles, so the whole load becomes asynchronous. CommonJS&#39;s require() must return a value immediately. It cannot wait.</p><h4>How Node decides which system a file uses</h4><p>Node does not guess from the code alone. It follows these rules, in order:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/958/1*YzcgQxCAE1JWeVwYcXtZRQ.png" /></figure><p>The phrase “nearest package.json&quot; matters. Node walks up from the file&#39;s folder until it finds one. That means you can place a tiny package.json with just { &quot;type&quot;: &quot;module&quot; } inside one subfolder and turn only that folder into ESM. We will use this trick in the migration playbook.</p><p><strong>Beginner takeaway:</strong> .mjs and .cjs are explicit and never lie. Plain .js is decided by the nearest package.json. When in doubt, be explicit.</p><h3>Part 2: Why the two systems clash</h3><p>With the loading model in mind, the compatibility rules stop looking random. There are four possible directions, and for years one of them was blocked.</p><h4>Direction 1: ESM importing CommonJS (always worked)</h4><p>An ES module can import a CommonJS file because CommonJS is synchronous. Waiting for something that finishes immediately is never a problem.</p><pre>// app.mjs (ESM) importing a CJS package such as express<br>import express from &#39;express&#39;;        // default import = module.exports<br>import { Router } from &#39;express&#39;;     // named import: works only if Node can detect it</pre><p>The default import always gives you module.exports. Named imports are a best effort. Node scans the CommonJS source with a small static analyzer (called cjs-module-lexer) looking for patterns like exports.Router = .... If a package builds its exports dynamically, the analyzer cannot see them, and you get a SyntaxError saying the named export was not found. The safe fallback is:</p><pre>import pkg from &#39;some-cjs-lib&#39;;<br>const { helper } = pkg;               // destructure from the default import</pre><h4>Direction 2: CommonJS requiring ESM (blocked for years)</h4><p>This is the direction that hurt. require() is synchronous and must return a value right now. ESM evaluation was treated as potentially asynchronous because of top-level await. A synchronous function cannot wait on an asynchronous one, so Node simply refused:</p><pre>// logger.js (CJS) on Node 18 or Node 22.11<br>const chalk = require(&#39;chalk&#39;);       // chalk v5 is ESM-only<br>// → Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported.</pre><p>The only escape hatch was <strong>dynamic </strong><strong>import()</strong>, which works inside CommonJS but returns a promise:</p><pre>// logger.js (CJS) — the old workaround<br>let chalkPromise;<br><br>function getChalk() {<br>  // import() is async, so we cache the promise and reuse it<br>  chalkPromise ??= import(&#39;chalk&#39;).then((mod) =&gt; mod.default);<br>  return chalkPromise;<br>}<br>async function logError(message) {<br>  const chalk = await getChalk();<br>  console.error(chalk.red(message));<br>}<br>module.exports = { logError };</pre><p>Read that code carefully, because it shows why the workaround spreads like a virus. logError used to be synchronous. Now it is async. Every caller must now await it, so every caller&#39;s caller becomes async too. One ESM-only dependency deep in the tree can force an async rewrite all the way up to your HTTP handlers. Most teams chose to pin the old CommonJS version instead.</p><h4>Why the wall was not really necessary</h4><p>Node contributors eventually pointed out a subtle fact: ESM is only asynchronous <strong>when the module graph actually contains top-level </strong><strong>await</strong>. The vast majority of packages never use it. For those packages, all three phases can complete synchronously, so there is no technical reason require() cannot load them. That insight became the require(esm) feature, covered next.</p><h4>The other everyday differences</h4><p>Before moving on, here is the full list of differences you will hit while migrating. Keep it handy.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WiyuV9j7tM_1HGzXdnzrKA.png" /></figure><h3>Part 3: The game-changer, require(esm)</h3><p>On any supported Node.js release today, CommonJS can require() an ES module synchronously, as long as that module&#39;s graph has no top-level await. The feature was marked stable in Node v25.4.0 and backported to the 24.x LTS line, according to the <a href="https://nodejs.org/api/modules.html">Node.js modules documentation</a> and this <a href="https://blog.openreplay.com/end-dual-cjs-esm-builds-nodejs/">OpenReplay timeline</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fNeN0r47-CCS-fEmKV2Mmg.png" /></figure><p>What this means for your version policy:</p><ul><li><strong>Node 22.12+</strong> has it on by default. This is the practical minimum for any migration in this article.</li><li><strong>Node 20.19+</strong> also has it, but Node 20 reached end-of-life on April 30, 2026. Do not plan around it.</li><li><strong>Node 18 never received it.</strong> If you are still on 18, upgrading Node is step zero.</li></ul><h4>What require() actually returns</h4><p>This is the part that trips people up. When you require() an ES module, you do <strong>not</strong> get the default export directly. You get the <strong>module namespace object</strong>, the same thing await import() gives you.</p><pre>// point.mjs (ESM)<br>export default class Point {<br>  constructor(x, y) { this.x = x; this.y = y; }<br>}<br>export function distance(a, b) {<br>  return Math.hypot(b.x - a.x, b.y - a.y);<br>}</pre><pre>// main.js (CJS)<br>const mod = require(&#39;./point.mjs&#39;);<br>console.log(mod);<br>// [Module: null prototype] {<br>//   __esModule: true,<br>//   default: [class Point],<br>//   distance: [Function: distance]<br>// }<br><br>const Point = mod.default;           // the class lives on .default<br>const { distance } = mod;            // named exports are plain properties<br>const p = new Point(0, 0);<br>console.log(distance(p, new Point(3, 4))); // 5</pre><p>The extra __esModule: true flag is added for compatibility with code produced by transpilers like Babel and older TypeScript. You should not depend on it in hand-written code.</p><p>The practical consequence: when you replace an old CommonJS version of a package with its ESM-only version, const chalk = require(&#39;chalk&#39;) may now return an object with .default instead of the function itself. A defensive pattern that works for both shapes:</p><pre>// Works whether the package exposes module.exports or an ESM default<br>const chalkModule = require(&#39;chalk&#39;);<br>const chalk = chalkModule.default ?? chalkModule;</pre><h4>The &quot;module.exports&quot; escape hatch (for library authors)</h4><p>If you maintain an ESM package and want CommonJS users to receive a plain value, Node lets you export something under the special string name &quot;module.exports&quot;:</p><pre>// point.mjs<br>export function distance(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); }<br><br>export default class Point {<br>  constructor(x, y) { this.x = x; this.y = y; }<br>  static distance = distance;        // attach named helpers so CJS users keep them<br>}<br>export { Point as &#39;module.exports&#39; }; // require() now returns Point itself</pre><pre>// consumer.js (CJS)<br>const Point = require(&#39;./point.mjs&#39;); // [class Point], no .default needed<br>const { distance } = require(&#39;./point.mjs&#39;); // works because of the static property</pre><p>The trade-off: once you use &quot;module.exports&quot;, CommonJS consumers only see that one value, so named exports disappear unless you attach them to it, as above.</p><h4>The one hard limit: top-level await</h4><p>Here is the full decision path Node follows on every require() call:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6B_ER8lnut1G1A7HIyL3TA.png" /></figure><p>If the required module, <strong>or anything it imports, however deep</strong>, uses top-level await, Node throws ERR_REQUIRE_ASYNC_MODULE and tells you to use import(). The word &quot;graph&quot; matters. Your direct dependency may be fine while a dependency three levels down adds a top-level await in a patch release.</p><p>That is not hypothetical. In April 2026, a release of lru-cache added top-level await to its ESM build. That broke CommonJS projects that reached it through a chain of ESM-only packages, including jsdom users. The maintainer reverted it, but the lesson stands: <strong>the top-level-await-free graph is now a compatibility contract.</strong></p><h4>Diagnostic tools built into Node</h4><pre># Print a warning every time require() loads an ES module (find hidden usage)<br>node --trace-require-module=all server.js<br><br># When ERR_REQUIRE_ASYNC_MODULE happens, print where the top-level awaits are<br>node --experimental-print-required-tla server.js<br><br># Emergency switch: turn the feature off to compare behavior<br>node --no-require-module server.js</pre><p>And you can feature-detect at runtime:</p><pre>if (!process.features.require_module) {<br>  throw new Error(&#39;This service needs Node.js 22.12+ (require(esm) support).&#39;);<br>}</pre><h4>The module-sync export condition</h4><p>Node also resolves a &quot;module-sync&quot; condition in a package&#39;s exports map when require(esm) is available. Library authors use it to point modern Node at the ESM build for both require and import, while older runtimes fall back to a CommonJS file. You will see it in Part 8.</p><h3>Part 4: Playbook step 1, audit before you touch anything</h3><p>A migration fails when it starts with code changes. It succeeds when it starts with an inventory. The audit answers four questions: which Node version runs everywhere, which dependencies are ESM-only, which of them could carry top-level await, and how much of your own code depends on CommonJS-only features.</p><h4>4.1 Check the runtime in every environment</h4><p>Your laptop is not the problem. CI images, Docker base images, serverless runtimes and that one cron box are. Run this everywhere your code runs:</p><pre>node -p &quot;process.version + &#39;  require(esm): &#39; + process.features.require_module&quot;<br># v24.11.0  require(esm): true</pre><p>Then make the requirement explicit in package.json so installs warn early:</p><pre>{<br>  &quot;engines&quot;: { &quot;node&quot;: &quot;&gt;=22.12.0&quot; }<br>}</pre><p>Add engine-strict=true to your project&#39;s .npmrc if you want npm install to fail instead of warn on an older runtime.</p><h4>4.2 Classify every dependency</h4><p>The script below reads each dependency’s package.json and labels it <strong>CJS</strong>, <strong>dual</strong> (ships both), or <strong>ESM-only</strong>. It is a heuristic, but it is accurate enough to plan with. Save it as scripts/esm-audit.cjs. We use the .cjs extension so it runs as CommonJS no matter how the project is configured later.</p><pre>// scripts/esm-audit.cjs<br>const fs = require(&#39;node:fs&#39;);<br>const path = require(&#39;node:path&#39;);<br><br>const root = process.cwd();<br>const pkg = JSON.parse(fs.readFileSync(path.join(root, &#39;package.json&#39;), &#39;utf8&#39;));<br>const deps = Object.keys({ ...pkg.dependencies });<br>// Collect every condition name used anywhere in an &quot;exports&quot; map<br>// e.g. { &quot;.&quot;: { &quot;import&quot;: &quot;...&quot;, &quot;require&quot;: &quot;...&quot; } } -&gt; { import, require }<br>function conditionKeys(exportsField, keys = new Set()) {<br>  if (exportsField &amp;&amp; typeof exportsField === &#39;object&#39;) {<br>    for (const [key, value] of Object.entries(exportsField)) {<br>      if (!key.startsWith(&#39;.&#39;)) keys.add(key); // &quot;.&quot;, &quot;./sub&quot; are paths, not conditions<br>      conditionKeys(value, keys);<br>    }<br>  }<br>  return keys;<br>}<br>function classify(meta) {<br>  const keys = conditionKeys(meta.exports);<br>  const speaksEsm = meta.type === &#39;module&#39; || keys.has(&#39;import&#39;) || keys.has(&#39;module-sync&#39;);<br>  const speaksCjs =<br>    keys.has(&#39;require&#39;) ||<br>    (meta.type !== &#39;module&#39; &amp;&amp; !keys.has(&#39;import&#39;)) ||<br>    String(meta.main || &#39;&#39;).endsWith(&#39;.cjs&#39;);<br>  if (speaksEsm &amp;&amp; speaksCjs) return &#39;dual&#39;;<br>  return speaksEsm ? &#39;ESM-only&#39; : &#39;CJS&#39;;<br>}<br>const rows = deps.map((name) =&gt; {<br>  const file = path.join(root, &#39;node_modules&#39;, name, &#39;package.json&#39;);<br>  const meta = JSON.parse(fs.readFileSync(file, &#39;utf8&#39;));<br>  return { name, version: meta.version, kind: classify(meta) };<br>});<br>console.table(rows.sort((a, b) =&gt; a.kind.localeCompare(b.kind)));</pre><p>Run it with node scripts/esm-audit.cjs. You will get a table like this:</p><pre>┌─────────┬───────────┬───────────┬────────────┐<br>│ (index) │ name      │ version   │ kind       │<br>├─────────┼───────────┼───────────┼────────────┤<br>│ 0       │ &#39;express&#39; │ &#39;5.3.0&#39;   │ &#39;CJS&#39;      │<br>│ 1       │ &#39;zod&#39;     │ &#39;3.25.76&#39; │ &#39;dual&#39;     │<br>│ 2       │ &#39;chalk&#39;   │ &#39;5.6.2&#39;   │ &#39;ESM-only&#39; │<br>│ 3       │ &#39;nanoid&#39;  │ &#39;5.1.16&#39;  │ &#39;ESM-only&#39; │<br>└─────────┴───────────┴───────────┴────────────┘</pre><p><strong>How the code works.</strong> A package can declare its format in two places. The &quot;type&quot; field tells Node how to treat its .js files. The &quot;exports&quot; map lists entry points, optionally keyed by <em>conditions</em> such as import, require and module-sync. The recursive conditionKeys function walks that map, which can be nested, and gathers all the condition names. If a package offers a require entry, CommonJS users get a CJS file. If it only offers import or is type: module, it is ESM-only. Version numbers in the sample output are illustrative.</p><h4>4.3 Find packages you held back</h4><p>ESM-only releases are often the reason a dependency is stuck. Run:</p><pre>npm outdated</pre><p>Look for major versions you skipped, then check each changelog for words like “pure ESM” or “drop CommonJS”. Those are your migration candidates, and often your security backlog too.</p><h4>4.4 Smoke-test require() for every dependency</h4><p>Classification tells you what <em>should</em> work. This test tells you what <em>does</em> work on your real Node version, including the top-level await trap:</p><pre>// scripts/require-smoke.cjs<br>const pkg = require(&#39;../package.json&#39;);<br><br>const results = Object.keys(pkg.dependencies ?? {}).map((name) =&gt; {<br>  try {<br>    const mod = require(name);<br>    const shape = mod &amp;&amp; mod.__esModule ? &#39;namespace (.default)&#39; : typeof mod;<br>    return { name, status: &#39;ok&#39;, shape };<br>  } catch (err) {<br>    return { name, status: err.code ?? &#39;error&#39;, shape: err.message.split(&#39;\n&#39;)[0] };<br>  }<br>});<br>console.table(results);<br>process.exitCode = results.some((r) =&gt; r.status !== &#39;ok&#39;) ? 1 : 0;</pre><p>Read the status column like this:</p><p>Here is the real output from a test project on Node 22:</p><pre>┌─────────┬───────────┬────────┬────────────────────────┐<br>│ (index) │ name      │ status │ shape                  │<br>├─────────┼───────────┼────────┼────────────────────────┤<br>│ 0       │ &#39;chalk&#39;   │ &#39;ok&#39;   │ &#39;namespace (.default)&#39; │<br>│ 1       │ &#39;express&#39; │ &#39;ok&#39;   │ &#39;function&#39;             │<br>│ 2       │ &#39;nanoid&#39;  │ &#39;ok&#39;   │ &#39;object&#39;               │<br>│ 3       │ &#39;zod&#39;     │ &#39;ok&#39;   │ &#39;namespace (.default)&#39; │<br>└─────────┴───────────┴────────┴────────────────────────┘</pre><ul><li>ok with shape namespace (.default) means the package has a default export reached through .default. For ESM-only packages like chalk, that is require(esm) at work. Use .default in your code.</li><li>ok with shape object can also be an ESM namespace. nanoid has only named exports, so const { nanoid } = require(&#39;nanoid&#39;) just works.</li><li>ERR_REQUIRE_ASYNC_MODULE means a top-level await sits somewhere in that package&#39;s graph. It needs Strategy B from Part 5.</li><li>ERR_PACKAGE_PATH_NOT_EXPORTED usually means the package has no main entry (a CLI or types-only package). That is normally fine to ignore.</li></ul><p>One caution: requiring a package runs its top-level code. Run this in CI or a sandbox, not against production credentials.</p><h4>4.5 Measure your own CommonJS-only habits</h4><p>These patterns need rewriting when a file becomes ESM. Counting them tells you how big the job is.</p><pre># Count CommonJS-only constructs in your source (ripgrep)<br>rg -c &quot;__dirname|__filename&quot; src<br>rg -c &quot;require\.(resolve|cache|main)&quot; src<br>rg -n &quot;require\([^&#39;\&quot;]&quot; src              # dynamic require with a variable<br>rg -n &quot;require\([&#39;\&quot;][^&#39;\&quot;]+\.json[&#39;\&quot;]\)&quot; src   # JSON requires<br>rg -n &quot;module\.exports\s*=\s*&quot; src | wc -l</pre><h4>4.6 Write it down</h4><p>Put the findings into a simple tracker. A short table like the one below becomes your migration backlog.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/982/1*Ol7NE61aJhwaFop8y3sGMw.png" /></figure><h3>Part 5: Playbook step 2, choose a strategy</h3><p>There is no single correct migration. There are three sensible strategies, and the audit tells you which one fits. Most large codebases use all three over time: A today, B for a few awkward modules, and C as the long-term direction.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PEfdAkIWOYSf3wRS7rs9Cw.png" /></figure><h4>Strategy A: stay CommonJS, consume ESM through require(esm)</h4><p>This is the lowest-effort option and, for many services, the right final answer. You keep every file as CommonJS. You upgrade ESM-only dependencies and load them with require().</p><pre>// src/lib/ids.js (still CommonJS)<br>const { nanoid } = require(&#39;nanoid&#39;);   // nanoid 5 is ESM-only; named export works directly<br><br>function newOrderId() {<br>  return `ord_${nanoid(12)}`;<br>}<br>module.exports = { newOrderId };</pre><pre>// src/lib/logger.js (still CommonJS)<br>const chalkModule = require(&#39;chalk&#39;);   // chalk 5 is ESM-only with a default export<br>const chalk = chalkModule.default;      // the function lives on .default<br><br>function warn(message) {<br>  console.warn(chalk.yellow(`⚠ ${message}`));<br>}<br>module.exports = { warn };</pre><p>Compare this with the async workaround in Part 2. warn() stays synchronous, its callers do not change, and the upgrade is a two-line diff. That is the whole point of require(esm).</p><p><strong>Choose A when</strong> your Node floor is 22.12+, your ESM dependencies have no top-level await, and the codebase is stable or in maintenance mode.</p><h4>Strategy B: create an async boundary for the awkward modules</h4><p>Some modules truly need top-level await: a config loader that fetches secrets, a module that initializes a database pool, or a third-party package that ships with it. require() will never load these. The fix is to move the asynchronous work to <strong>one</strong> place, the application&#39;s startup, instead of letting async leak through the whole codebase.</p><pre>// src/config.mjs (ESM, uses top-level await on purpose)<br>import { readFile } from &#39;node:fs/promises&#39;;<br><br>const raw = await readFile(new URL(&#39;../config.json&#39;, import.meta.url), &#39;utf8&#39;);<br>export const config = Object.freeze(JSON.parse(raw));</pre><pre>// src/bootstrap.js (CommonJS entry point)<br>async function main() {<br>  // 1. Load every async ESM module ONCE, at startup<br>  const { config } = await import(&#39;./config.mjs&#39;);<br><br>// 2. Hand the resolved values to the synchronous CommonJS world<br>  const { createServer } = require(&#39;./server&#39;);<br>  const app = createServer(config);<br>  app.listen(config.port, () =&gt; console.log(`listening on ${config.port}`));<br>}<br>main().catch((err) =&gt; {<br>  console.error(&#39;Startup failed:&#39;, err);<br>  process.exit(1);<br>});</pre><pre>// src/server.js (CommonJS, fully synchronous, receives config as an argument)<br>const express = require(&#39;express&#39;);<br><br>function createServer(config) {<br>  const app = express();<br>  app.get(&#39;/health&#39;, (_req, res) =&gt; res.json({ ok: true, env: config.env }));<br>  return app;<br>}<br>module.exports = { createServer };</pre><p><strong>Why this works.</strong> await import() is allowed inside CommonJS because it returns a promise, and promises can wait for top-level await. By doing it once inside main(), the rest of the application receives plain values through <strong>dependency injection</strong> (passing them as function arguments). No other function has to become async.</p><p>If you need an ESM-with-TLA module lazily, after startup, cache the promise so it is only loaded once:</p><pre>// src/lib/lazy-esm.js (CommonJS helper)<br>const cache = new Map();<br><br>/** Load an ES module once and reuse the same promise for every caller. */<br>function lazyImport(specifier) {<br>  if (!cache.has(specifier)) cache.set(specifier, import(specifier));<br>  return cache.get(specifier);<br>}<br>module.exports = { lazyImport };</pre><p><strong>Choose B when</strong> the smoke test shows ERR_REQUIRE_ASYNC_MODULE, or your own code needs top-level await for initialization.</p><h4>Strategy C: incremental full migration to ESM</h4><p>If the codebase will live for years, moving it to ESM pays off. You get the language standard, static analysis that tools understand, better tree-shaking in bundlers, and no more interop questions. The key word is <strong>incremental</strong>. Because require(esm) lets CommonJS files load ESM files, you can convert one folder at a time and ship after every step. Part 6 shows exactly how.</p><p><strong>Choose C when</strong> the codebase is actively developed, you want new code written in modern syntax, or you publish packages consumed by ESM-first tools.</p><h4>Strategy D: when you cannot upgrade Node yet</h4><p>If some environment is stuck below 22.12, you have two temporary options: pin the last CommonJS-compatible version of each library, or use the async import() pattern from Part 2 in the few places that need ESM. Treat both as debt with a deadline, and put the Node upgrade at the top of the backlog.</p><h3>Part 6: Playbook step 3, migrate incrementally</h3><p>The golden rule: <strong>never do a big-bang rewrite.</strong> Convert small pieces, keep the app shippable after every step, and let require(esm) hold the two worlds together while you work.</p><h3>6.1 Leaf-first waves</h3><p>A <strong>leaf</strong> is a file that does not import any of your other internal files: utilities, constants, formatters, validation schemas. Leaves are the safest place to start because nothing inside them can break when they change format. Their parents keep calling require(), which now loads ESM.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-AZ2OkIz5XbLnva9UAbHcw.png" /></figure><p>The picture shows a typical order. Wave 1 converts utils and config. Wave 2 converts the services and routes that use them. The entry point, server.js, goes last, because CommonJS parents can load ESM children but you want to keep the entry point stable until everything below it is done.</p><p>To find your leaves automatically, tools like madge can draw your internal dependency graph:</p><pre>npx madge --leaves src/          # files with no internal dependencies<br>npx madge --circular src/        # circular imports to untangle first</pre><p>Fix circular dependencies before converting. Cycles that cross the CJS/ESM boundary are the hardest bugs in this whole process, and Node may refuse them with ERR_REQUIRE_CYCLE_MODULE.</p><h4>6.2 Flip one folder at a time with a nested package.json</h4><p>Remember the “nearest package.json&quot; rule from Part 1. You do not need to rename files to .mjs. Create a one-line file named package.json inside the folder you are converting (for example src/utils/package.json):</p><pre>{ &quot;type&quot;: &quot;module&quot; }</pre><p>Every .js file under src/utils/ is now ESM. Everything else stays CommonJS. Here is a tested, real example:</p><pre>src/<br>├── app.js                 ← CommonJS (root package.json has &quot;type&quot;: &quot;commonjs&quot;)<br>└── utils/<br>    ├── package.json       ← { &quot;type&quot;: &quot;module&quot; }<br>    ├── defaults.json<br>    ├── format.js          ← ESM now<br>    └── slug.js            ← ESM now</pre><pre>// src/utils/format.js (ESM)<br>import defaults from &#39;./defaults.json&#39; with { type: &#39;json&#39; };<br><br>export function money(cents) {<br>  return `$${(cents / 100).toFixed(2)}`;<br>}<br>export { defaults };</pre><pre>// src/app.js (still CommonJS) — no changes needed to load the converted folder<br>const { money, defaults } = require(&#39;./utils/format.js&#39;);<br>console.log(money(1999), defaults.port); // $19.99 3000</pre><p>Notice one change you <em>may</em> need in the parent: the extension. CommonJS lets you write require(&#39;./utils/format&#39;), and that still works, but writing the full path now makes the eventual switch to import mechanical.</p><h4>6.3 Keep parents unchanged with the &quot;module.exports&quot; trick</h4><p>The biggest source of churn during migration is the .default problem. If slug.js used to do module.exports = slugify, every parent does const slugify = require(&#39;./slug&#39;). After conversion to export default, those parents would receive a namespace object and break.</p><p>The fix is a single line in the converted file:</p><pre>// src/utils/slug.js (ESM)<br>export default function slugify(text) {<br>  return text.toLowerCase().trim().replace(/\s+/g, &#39;-&#39;);<br>}<br><br>// Transitional: CommonJS parents get the function directly, exactly like before<br>export { slugify as &#39;module.exports&#39; };</pre><pre>// src/app.js (CommonJS) — unchanged<br>const slugify = require(&#39;./utils/slug.js&#39;);<br>slugify(&#39;Hello World&#39;); // &#39;hello-world&#39;</pre><p>ESM importers still use import slugify from &#39;./slug.js&#39;. Once every parent is converted, delete the transitional line.</p><h4>6.4 The conversion cookbook</h4><p>Each pattern below shows the CommonJS original and its ESM replacement. Together they cover the vast majority of real-world files.</p><p><strong>Imports and exports</strong></p><pre>// CommonJS<br>const fs = require(&#39;fs&#39;);<br>const { join } = require(&#39;path&#39;);<br>const db = require(&#39;./db&#39;);<br>module.exports = { getUser, saveUser };<br>module.exports.helper = helper;</pre><pre>// ESM<br>import fs from &#39;node:fs&#39;;<br>import { join } from &#39;node:path&#39;;<br>import db from &#39;./db.js&#39;;               // extension is mandatory<br>export { getUser, saveUser };<br>export { helper };</pre><p>The node: prefix is optional for built-ins but makes it obvious which imports are core modules. The .js extension is not optional for relative paths.</p><p><strong>__dirname and </strong><strong>__filename</strong></p><pre>// CommonJS<br>const templates = path.join(__dirname, &#39;templates&#39;);</pre><pre>// ESM (Node 20.11+)<br>const templates = path.join(import.meta.dirname, &#39;templates&#39;);<br><br>// ESM, portable fallback for any Node version<br>import { fileURLToPath } from &#39;node:url&#39;;<br>import { dirname } from &#39;node:path&#39;;<br>const __filename = fileURLToPath(import.meta.url);<br>const __dirname = dirname(__filename);</pre><p>import.meta.url is the current file&#39;s location as a file:// URL. fileURLToPath converts it to a normal path. Many file APIs also accept URLs directly, so new URL(&#39;./templates/&#39;, import.meta.url) is often the cleanest option.</p><p><strong>JSON files</strong></p><pre>// CommonJS<br>const pkg = require(&#39;./package.json&#39;);</pre><pre>// ESM, using import attributes<br>import pkg from &#39;./package.json&#39; with { type: &#39;json&#39; };<br><br>// ESM, reading at runtime (useful when the file changes or is large)<br>import { readFile } from &#39;node:fs/promises&#39;;<br>const pkg = JSON.parse(await readFile(new URL(&#39;./package.json&#39;, import.meta.url), &#39;utf8&#39;));</pre><p>Careful: the second option uses top-level await. That makes this module impossible for CommonJS parents to require(). In a mixed codebase, prefer the import-attribute version.</p><p><strong>require.resolve</strong></p><pre>// CommonJS<br>const workerPath = require.resolve(&#39;./worker.js&#39;);</pre><pre>// ESM: returns a file:// URL string<br>const workerUrl = import.meta.resolve(&#39;./worker.js&#39;);</pre><p><strong>Conditional and dynamic loading</strong></p><pre>// CommonJS: load an optional dependency only when configured<br>let redis = null;<br>if (process.env.REDIS_URL) {<br>  redis = require(&#39;ioredis&#39;);<br>}</pre><pre>// ESM: use dynamic import(); it returns a promise of the namespace<br>let redis = null;<br>if (process.env.REDIS_URL) {<br>  const { default: Redis } = await import(&#39;ioredis&#39;);<br>  redis = new Redis(process.env.REDIS_URL);<br>}</pre><p>If this lives in a module that CommonJS still require()s, wrap it in an async function instead of using top-level await, for the same reason as the JSON example.</p><p><strong>When you truly need </strong><strong>require inside ESM</strong></p><p>Some old plugins or native addons expect require. Create one:</p><pre>// ESM<br>import { createRequire } from &#39;node:module&#39;;<br>const require = createRequire(import.meta.url);<br><br>const legacyPlugin = require(&#39;./legacy-plugin.cjs&#39;);<br>const addon = require(&#39;./build/Release/addon.node&#39;); // native addons load via require</pre><p><strong>“Is this file the entry point?”</strong></p><pre>// CommonJS<br>if (require.main === module) main();</pre><pre>// ESM<br>import { fileURLToPath } from &#39;node:url&#39;;<br>if (process.argv[1] === fileURLToPath(import.meta.url)) main();</pre><h4>6.5 The final flip</h4><p>When most of the codebase is ESM, invert the default instead of adding more nested package.json files:</p><ol><li>Set &quot;type&quot;: &quot;module&quot; in the root package.json.</li><li>Rename the remaining CommonJS files to .cjs. Config files for older tools (.eslintrc.cjs, jest.config.cjs) often fall into this group.</li><li>Delete the nested { &quot;type&quot;: &quot;module&quot; } files, since they are now redundant.</li><li>Remove the transitional export { x as &#39;module.exports&#39; } lines once no CommonJS file imports those modules.</li><li>Run the full test suite and the smoke script from Part 4.</li></ol><h4>6.6 Ship every wave</h4><p>Each wave should be its own pull request with its own deploy. A practical rhythm for a mid-size service:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/677/1*KWWihCnyGMmUouJDeTHZiA.png" /></figure><h3>Part 7: Playbook step 4, harden your tooling</h3><p>Code is rarely what breaks during an ESM migration. Tooling is: the TypeScript compiler, the test runner, and dev scripts that quietly assumed CommonJS. Fix these deliberately.</p><h4>7.1 TypeScript</h4><p>TypeScript has to know which module system Node will use, so it can emit the right output and check imports correctly. For Node.js projects, use the Node-aware settings:</p><pre>// tsconfig.json<br>{<br>  &quot;compilerOptions&quot;: {<br>    &quot;module&quot;: &quot;nodenext&quot;,<br>    &quot;moduleResolution&quot;: &quot;nodenext&quot;,<br>    &quot;target&quot;: &quot;es2023&quot;,<br>    &quot;verbatimModuleSyntax&quot;: true,<br>    &quot;rewriteRelativeImportExtensions&quot;: true,<br>    &quot;outDir&quot;: &quot;dist&quot;,<br>    &quot;strict&quot;: true<br>  }<br>}</pre><p>What each option does, in plain words:</p><ul><li><strong>module: nodenext</strong> makes TypeScript follow Node&#39;s real rules. It reads the same &quot;type&quot; field and file extensions Node does, so a .ts file compiles to ESM or CommonJS depending on where it sits. It also understands require(esm), according to the <a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-9.html">TypeScript 5.9 release notes</a>. If you prefer a setting that never changes behavior between releases, TypeScript 5.9 added the stable <strong>module: node20</strong>.</li><li><strong>verbatimModuleSyntax</strong> tells TypeScript to write your import and export statements exactly as you wrote them, and forces you to mark type-only imports with import type. That removes a whole class of &quot;works in TS, crashes in Node&quot; bugs. One caveat for mixed codebases: with this flag on, TypeScript files that compile to CommonJS must use `import x = require(&#39;x&#39;)` instead of `import x from &#39;x&#39;`. Many teams switch it on per folder as each folder becomes ESM, or right after the final flip.</li><li><strong>rewriteRelativeImportExtensions</strong> lets you write import { x } from &#39;./util.ts&#39; and have it emitted as ./util.js. Without it, you must write ./util.js in your TypeScript source even though the file on disk is util.ts. That surprises everyone the first time, but it is correct: the import path describes the <em>output</em> file Node will load.</li></ul><p>TypeScript also supports per-file formats through extensions: .mts always compiles to ESM (.mjs), and .cts always compiles to CommonJS (.cjs). They are the TypeScript twins of the .mjs/.cjs trick.</p><p>For running TypeScript directly during development, replace ts-node with tsx, which handles both module systems without extra configuration:</p><pre>{<br>  &quot;scripts&quot;: {<br>    &quot;dev&quot;: &quot;tsx watch src/server.ts&quot;,<br>    &quot;build&quot;: &quot;tsc -p tsconfig.json&quot;,<br>    &quot;start&quot;: &quot;node dist/server.js&quot;<br>  }<br>}</pre><h4>7.2 Tests</h4><p>The test runner is where most teams lose a day. Know where your runner stands:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/825/1*0nQ3PiMeljhD86xb8DpA8g.png" /></figure><p>The Jest status comes from the <a href="https://newreleases.io/project/npm/jest/release/30.4.0">Jest 30.4.0 release notes</a>, which say require(esm) is supported but still requires the experimental flag.</p><p><strong>If you stay on Jest with a CommonJS codebase</strong>, the simplest fix for an ESM-only dependency is to let Jest transpile it. By default, Jest skips everything in node_modules. You add exceptions:</p><pre>// jest.config.cjs<br>module.exports = {<br>  testEnvironment: &#39;node&#39;,<br>  transform: { &#39;^.+\\.[cm]?[jt]sx?$&#39;: &#39;babel-jest&#39; },<br>  // Transform these ESM-only packages instead of skipping them<br>  transformIgnorePatterns: [&#39;/node_modules/(?!(chalk|nanoid|p-queue)/)&#39;],<br>};</pre><p>The regular expression reads as: “ignore everything inside node_modules <em>except</em> chalk, nanoid and p-queue&quot;. Those three get converted to CommonJS for the test run only.</p><p><strong>If you run Jest in native ESM mode</strong>, mocking changes. jest.mock() relies on being hoisted above require() calls, and static import statements cannot be intercepted that way. Use the ESM API and a dynamic import:</p><pre>// orders.test.js (ESM, run with NODE_OPTIONS=--experimental-vm-modules)<br>import { jest } from &#39;@jest/globals&#39;;<br><br>// 1. Register the mock BEFORE the module under test is loaded<br>jest.unstable_mockModule(&#39;./payments.js&#39;, () =&gt; ({<br>  charge: jest.fn().mockResolvedValue({ id: &#39;ch_123&#39; }),<br>}));<br>// 2. Load the module under test dynamically, so it sees the mock<br>const { placeOrder } = await import(&#39;./orders.js&#39;);<br>const { charge } = await import(&#39;./payments.js&#39;);<br>test(&#39;charges the customer&#39;, async () =&gt; {<br>  await placeOrder({ total: 4200 });<br>  expect(charge).toHaveBeenCalledWith(4200);<br>});</pre><p>The order matters. Because ESM links the whole graph before running anything (Part 1, phase 2), the mock must exist before orders.js is imported. The dynamic import() delays that load until after the mock is registered.</p><p>For new projects, or if Jest friction is high, <strong>Vitest</strong> offers an almost identical API with native ESM support, and the built-in <strong>node:test</strong> runner needs no configuration at all.</p><h4>7.3 Linting</h4><p>Let the linter catch mistakes before Node does:</p><pre>// eslint.config.js (flat config, ESM)<br>import n from &#39;eslint-plugin-n&#39;;<br><br>export default [<br>  n.configs[&#39;flat/recommended&#39;],<br>  {<br>    rules: {<br>      &#39;n/no-missing-import&#39;: &#39;error&#39;,       // catches &#39;./util&#39; without .js<br>      &#39;n/file-extension-in-import&#39;: [&#39;error&#39;, &#39;always&#39;],<br>      &#39;no-restricted-syntax&#39;: [<br>        &#39;error&#39;,<br>        { selector: &quot;CallExpression[callee.name=&#39;require&#39;]&quot;, message: &#39;Use import in ESM files.&#39; },<br>      ],<br>    },<br>  },<br>];</pre><p>Apply the require restriction only to folders you have already converted, using a files glob, so the linter tracks your migration progress.</p><h4>7.4 A CI guard for the top-level-await contract</h4><p>While parts of your codebase are still CommonJS, any ESM module they require() must stay free of top-level await. A tiny test enforces that rule automatically:</p><pre>// test/require-graph.test.cjs  (runs with node --test)<br>const test = require(&#39;node:test&#39;);<br>const assert = require(&#39;node:assert&#39;);<br><br>// Every ESM entry point that CommonJS code still require()s<br>const requiredByCjs = [&#39;../src/utils/format.js&#39;, &#39;../src/utils/slug.js&#39;, &#39;chalk&#39;, &#39;nanoid&#39;];<br>for (const specifier of requiredByCjs) {<br>  test(`require(${specifier}) stays synchronous`, () =&gt; {<br>    assert.doesNotThrow(() =&gt; require(specifier));<br>  });<br>}</pre><p>If someone adds a top-level await to format.js, or a dependency update sneaks one in, this test fails in CI with ERR_REQUIRE_ASYNC_MODULE instead of failing in production at startup.</p><h3>Part 8: If you publish packages (internal or public)</h3><p>Many CommonJS-heavy companies also own a set of internal npm packages: a shared logger, an auth client, a design-token library. Once your consumers run Node 22.12+, the best format for those packages is usually <strong>ESM-only</strong>.</p><h4>8.1 Why dual builds are now a liability</h4><p>For years, the standard advice was to ship both a CommonJS build and an ESM build. That solved the ERR_REQUIRE_ESM problem but created another one: the <strong>dual-package hazard</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*031bjuuNMhGYg64GW3s-LQ.png" /></figure><p>If one part of an application loads your package with require() and another part uses import, Node can load <strong>both</strong> builds. They are separate module instances with separate state. A cache filled through the CommonJS copy is empty in the ESM copy. A class exported from one copy fails instanceof checks against objects created by the other. These bugs are miserable to debug because each copy looks correct on its own.</p><p>With require(esm), CommonJS consumers load the real ES module, so there is only one instance. Shipping ESM-only removes the hazard and halves your build output.</p><h4>8.2 A minimal ESM-only package.json</h4><pre>{<br>  &quot;name&quot;: &quot;@acme/logger&quot;,<br>  &quot;version&quot;: &quot;3.0.0&quot;,<br>  &quot;type&quot;: &quot;module&quot;,<br>  &quot;exports&quot;: {<br>    &quot;.&quot;: {<br>      &quot;types&quot;: &quot;./dist/index.d.ts&quot;,<br>      &quot;default&quot;: &quot;./dist/index.js&quot;<br>    }<br>  },<br>  &quot;files&quot;: [&quot;dist&quot;],<br>  &quot;engines&quot;: { &quot;node&quot;: &quot;&gt;=22.12.0&quot; }<br>}</pre><p>Field by field:</p><ul><li><strong>type: module</strong> makes every .js file in the package ESM.</li><li><strong>exports</strong> defines the public entry points. Anything not listed is private, and consumers get ERR_PACKAGE_PATH_NOT_EXPORTED if they try to deep-import it. The types condition must come first so TypeScript finds the declarations.</li><li><strong>engines</strong> documents the require(esm) requirement. Bump your major version when you make this change, since older Node users will break.</li></ul><h4>8.3 Make CommonJS consumers feel at home</h4><p>If your package has a default export, CommonJS users will otherwise need .default. Apply the trick from Part 3 so require(&#39;@acme/logger&#39;) returns the logger directly:</p><pre>// src/index.js<br>export function createLogger(name) {<br>  return {<br>    info: (msg) =&gt; console.log(`[${name}] ${msg}`),<br>    error: (msg) =&gt; console.error(`[${name}] ${msg}`),<br>  };<br>}<br><br>const defaultLogger = createLogger(&#39;app&#39;);<br>defaultLogger.createLogger = createLogger;      // keep the named export reachable from CJS<br>export default defaultLogger;<br>export { defaultLogger as &#39;module.exports&#39; };</pre><pre>// A CommonJS consumer<br>const logger = require(&#39;@acme/logger&#39;);         // the logger itself, no .default<br>const { createLogger } = require(&#39;@acme/logger&#39;);<br>logger.info(&#39;ready&#39;);</pre><h4>8.4 If you must still support older runtimes</h4><p>When some consumers cannot upgrade, use the module-sync condition. Modern Node uses the ESM file for both require and import, so there is still only one instance there. Older runtimes fall back to a CommonJS build.</p><pre>{<br>  &quot;type&quot;: &quot;module&quot;,<br>  &quot;exports&quot;: {<br>    &quot;.&quot;: {<br>      &quot;types&quot;: &quot;./dist/index.d.ts&quot;,<br>      &quot;module-sync&quot;: &quot;./dist/index.js&quot;,<br>      &quot;require&quot;: &quot;./dist/index.cjs&quot;,<br>      &quot;default&quot;: &quot;./dist/index.js&quot;<br>    }<br>  }<br>}</pre><p>Node checks conditions in the order they appear in the object, so module-sync must come before require. Treat the .cjs fallback as temporary and plan to remove it.</p><h4>8.5 Treat top-level await as a breaking change</h4><p>With ESM-only packages consumed through require(esm), your first top-level await, anywhere in the files your entry point imports, breaks every CommonJS consumer with ERR_REQUIRE_ASYNC_MODULE. That makes it a <strong>semver-major</strong> change. If you need async initialization, export an async function instead:</p><pre>// Breaks CommonJS consumers — avoid in libraries<br>export const client = await connect();<br><br>// Safe for everyone - the consumer decides when to await<br>let clientPromise;<br>export function getClient() {<br>  clientPromise ??= connect();<br>  return clientPromise;<br>}</pre><h4>8.6 Verify before publishing</h4><p>Two community tools catch most packaging mistakes:</p><pre>npx publint                              # checks exports, files, and format consistency<br>npx @arethetypeswrong/cli --pack .       # checks TypeScript types for each resolution mode</pre><p>Add both to your release pipeline, along with a test that require()s and import()s the packed tarball in a clean folder.</p><h3>Part 9: Error cheat sheet, checklist, and wrap-up</h3><h4>9.1 Errors you will meet, and what they mean</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jWK29ORF70zqDFB3OB0DFw.png" /></figure><h4>9.2 The one-page checklist</h4><p><strong>Audit</strong></p><ul><li>Every environment (local, CI, Docker, serverless, cron) runs Node 22.12 or newer</li><li>engines.node set to &gt;=22.12.0 in package.json</li><li>Dependencies classified as CJS, dual, or ESM-only (esm-audit.cjs)</li><li>require-smoke.cjs passes, or failures are understood</li><li>CommonJS-only patterns in your source counted</li></ul><p><strong>Choose</strong></p><ul><li>Strategy decided per area: A (stay CJS), B (async boundary), C (full ESM)</li><li>Modules that need top-level await isolated behind a startup await import()</li></ul><p><strong>Migrate</strong></p><ul><li>Held-back ESM-only dependencies upgraded through require(esm)</li><li>Circular dependencies removed before converting</li><li>Leaf folders converted with a nested { &quot;type&quot;: &quot;module&quot; }</li><li>Transitional export { x as &#39;module.exports&#39; } added where CJS parents expect a single value</li><li>Final flip: root &quot;type&quot;: &quot;module&quot;, leftovers renamed to .cjs, transitional exports removed</li></ul><p><strong>Harden</strong></p><ul><li>TypeScript on module: nodenext (or node20) with verbatimModuleSyntax</li><li>Test runner configured for ESM (Vitest, node:test, or Jest with transforms)</li><li>Lint rules enforce extensions and ban require in ESM folders</li><li>CI test guards the top-level-await contract for everything CJS still requires</li></ul><h3>9.3 Wrapping up</h3><p>The CommonJS/ESM split was painful for a decade because of one asymmetry: CommonJS could not load ESM. On every supported Node.js release, that asymmetry is gone for any module without top-level await. That changes the migration from an all-or-nothing rewrite into an ordinary engineering project.</p><p>The practical path for a CommonJS-heavy codebase looks like this:</p><ol><li><strong>Upgrade Node to 22.12+</strong> and make it explicit with engines.</li><li><strong>Stop pinning old dependencies.</strong> Upgrade ESM-only packages and load them with require(), minding .default.</li><li><strong>Isolate the async exceptions</strong> behind a single await import() at startup.</li><li><strong>Migrate leaf-first, in waves</strong>, if the codebase has a long future, shipping after each wave.</li><li><strong>Guard the new contract</strong> in CI: anything CommonJS requires must stay free of top-level await.</li></ol><p>You do not have to convert a single file today to benefit. Step 2 alone clears your security backlog of held-back majors. Everything after that can happen one folder at a time.</p><p>If this playbook helped, follow for more deep dives on Node.js architecture, and share the error you are fighting in the comments. I read them all.</p><h3>Sources</h3><ul><li><a href="https://nodejs.org/api/modules.html">Node.js documentation: Loading ECMAScript modules using require()</a></li><li><a href="https://blog.openreplay.com/end-dual-cjs-esm-builds-nodejs/">OpenReplay: The End of Dual CJS/ESM Builds in Node.js</a></li><li><a href="https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-9.html">TypeScript 5.9 release notes</a></li><li><a href="https://newreleases.io/project/npm/jest/release/30.4.0">Jest 30.4.0 release notes</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d165adf2fa57" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Gang Scheduling for Distributed Training: Why the Default K8s Scheduler Fails Multi-Pod AI Jobs]]></title>
            <link>https://medium.com/@vasanthank29/gang-scheduling-for-distributed-training-why-the-default-k8s-scheduler-fails-multi-pod-ai-jobs-fd8e7fd5f9ae?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/fd8e7fd5f9ae</guid>
            <category><![CDATA[mlops]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[cloud-native]]></category>
            <category><![CDATA[kubernetes]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Mon, 26 Oct 2026 13:01:03 GMT</pubDate>
            <atom:updated>2026-10-26T13:01:03.482Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*96lmasqEefiWUNFfTMnIfQ.png" /></figure><h4>The 7-out-of-8 problem</h4><p>You submit a distributed training job that needs 8 GPU workers. Kubernetes happily starts 7 of them. The 8th sits in Pending because the cluster ran out of GPUs. Those 7 running pods are not training anything. They are holding 7 expensive GPUs hostage while they wait for a teammate that may never arrive.</p><p>Now imagine a second team submits a similar 8-worker job at the same moment. Kubernetes splits the cluster between them: 4 GPUs to job A, 4 GPUs to job B. Neither job can ever start. Neither job will give its GPUs back. Your cluster is 100% “allocated” and 0% productive. This is called a <strong>resource deadlock</strong>, and it is one of the most common (and most expensive) surprises teams hit when they move AI training onto Kubernetes.</p><p>The fix has a name: <strong>gang scheduling</strong>. The idea is simple: a group of pods that must run together is scheduled <em>all-or-nothing</em>. Either every member of the gang gets a seat, or nobody sits down.</p><p>In this post we will build up the idea from first principles, so it works even if you have never trained a model across machines.</p><p>What you will learn:</p><ul><li>Why distributed training needs every worker alive at the same time</li><li>How the default kube-scheduler places pods, and why that design breaks multi-pod AI jobs</li><li>How to reproduce the deadlock with plain YAML</li><li>What gang scheduling is, and the key ideas behind it (minMember, minCount, quorum, timeouts)</li><li>Four ways to get gang scheduling today: the <strong>coscheduling plugin</strong>, <strong>Volcano</strong>, <strong>Kueue</strong>, and the new <strong>native Kubernetes Workload / PodGroup API</strong> (Beta in v1.37)</li><li>A hands-on lab on a local kind cluster, plus production tips</li></ul><p>Who this is for: developers and platform engineers who know basic Kubernetes objects (Pod, Job, Deployment) and want to run ML training reliably. Every code block comes with a line-by-line explanation, and every diagram in this post was drawn fresh for it.</p><h3>Part 1: A 5-minute primer on distributed training</h3><p>Before we blame the scheduler, we need to understand why AI training jobs are so picky. The short version: <strong>the workers of a training job talk to each other on every single step, so they are only useful as a complete set.</strong></p><h4>Why split training across many GPUs?</h4><p>Modern models are trained on huge datasets. One GPU would take weeks or months. So we split the work across many GPUs, often on many machines. The most common way to do this is called <strong>data parallelism</strong>:</p><ol><li>Every worker (one per GPU) holds a full copy of the model.</li><li>Each worker gets a <em>different</em> small batch of training data.</li><li>Each worker runs the forward pass and backward pass on its batch and computes <strong>gradients</strong> (the “which way should each weight move” numbers).</li><li>All workers <strong>average their gradients together</strong>. This step is called <strong>all-reduce</strong>.</li><li>Every worker applies the same averaged update, so all model copies stay identical.</li><li>Repeat for the next batch, thousands of times.</li></ol><p>Step 4 is the catch. It is a <strong>collective operation</strong>: it only completes when <em>every</em> worker has contributed. Libraries like PyTorch’s torch.distributed with the NCCL backend usually implement it as a ring, where each worker passes chunks of gradients to its neighbour.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2kzOg5xRrsnKGB_pR2NI6A.png" /></figure><p>Look at the right side of the diagram. If W3 never starts, W2 has nobody to send its gradients to, and W0 has nobody to receive from. The ring is broken. W0, W1 and W2 will sit at their very first step, waiting. Usually they wait until a timeout (often 30 minutes or more), crash, and get restarted, only to wait again.</p><h3>What this looks like in code</h3><p>Here is the skeleton of a typical PyTorch distributed training script. You do not need to run it yet. Just notice the two places where the code <em>blocks</em> until the full group is present.</p><pre># train.py  (simplified PyTorch DDP skeleton)<br>import os<br>import torch<br>import torch.distributed as dist<br>from torch.nn.parallel import DistributedDataParallel as DDP<br><br>def main():<br>    # 1. Join the process group.<br>    #    WORLD_SIZE = total number of workers (e.g. 8)<br>    #    RANK       = this worker&#39;s id (0..7)<br>    #    MASTER_ADDR/PORT = where rank 0 listens for the others<br>    dist.init_process_group(backend=&quot;nccl&quot;)   # BLOCKS until all WORLD_SIZE ranks have joined<br>    local_rank = int(os.environ[&quot;LOCAL_RANK&quot;])<br>    torch.cuda.set_device(local_rank)<br>    model = MyModel().cuda(local_rank)<br>    model = DDP(model, device_ids=[local_rank])  # wraps the model so gradients are all-reduced<br>    for batch in loader:<br>        loss = model(batch).loss<br>        loss.backward()        # BLOCKS inside the all-reduce until every rank sends its gradients<br>        optimizer.step()<br>        optimizer.zero_grad()<br>    dist.destroy_process_group()<br>if __name__ == &quot;__main__&quot;:<br>    main()</pre><p>Line by line, the important parts:</p><ul><li>dist.init_process_group(backend=&quot;nccl&quot;) is a <strong>rendezvous</strong>. Every worker connects to the master address and waits until WORLD_SIZE workers have checked in. If one worker is stuck in Pending, the other seven hang right here.</li><li>DDP(model, ...) wraps the model so that during loss.backward() the gradients are automatically all-reduced across all workers.</li><li>loss.backward() therefore also blocks on the slowest (or missing) worker, on every step.</li></ul><p>So a distributed training job is not “8 independent pods”. It is <strong>one program that happens to run in 8 places</strong>. It has exactly two useful states: all workers running, or none. Anything in between is pure waste. Keep that in mind as we look at how Kubernetes schedules pods.</p><h3>Part 2: How the default kube-scheduler thinks</h3><p>The kube-scheduler is the control-plane component that decides <strong>which node each pod runs on</strong>. It is excellent at what it was designed for: web servers, APIs and microservices, where every replica is independent. If 9 out of 10 replicas of a web server start, you are serving 90% of traffic. Partial success is still success.</p><p>That design shows up in one core fact: <strong>the scheduler works on one pod at a time.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Lwjze-xtk5KKTu7WKA1Q6g.png" /></figure><h4>The scheduling cycle, step by step</h4><p>New pods with no node assigned land in a priority queue called the <strong>activeQ</strong>. The scheduler pops one pod off the queue and runs it through a pipeline of plugin “extension points” defined by the <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/scheduling-framework/">scheduling framework</a>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/886/1*2eo1Tl96HJQis3f8GRpzNQ.png" /></figure><p>Then the scheduler moves to the <strong>next pod</strong> in the queue and does it all again.</p><h4>The missing question</h4><p>Notice what never happens in that loop. The scheduler never asks:</p><blockquote><em>“This pod belongs to an 8-worker training job. Can all 8 workers fit right now?”</em></blockquote><p>It only asks: “Does <em>this one</em> pod fit on <em>some</em> node <em>right now</em>?” Each decision is <strong>greedy</strong> (take the best option now) and <strong>local</strong> (ignore everything except this pod). The scheduler does not even know that A-worker-0 and A-worker-1 are related. To it, they are two unrelated pods that happen to have similar names.</p><p>Two more details make things worse for AI jobs:</p><ul><li><strong>Pods from different jobs interleave in the queue.</strong> If job A and job B are submitted at the same time, the queue looks like A0, B0, A1, B1, A2, B2... (ordered by priority and creation time). The scheduler hands out GPUs in that alternating order.</li><li><strong>Once bound, a pod keeps its resources.</strong> The scheduler will not take GPUs back from a running pod just because its job cannot make progress. (Preemption only kicks in for a <em>higher priority</em> pod, and it is also pod-by-pod.)</li></ul><p>One green box in the diagram deserves attention: <strong>Permit</strong>. It can tell a pod to <em>wait</em> without binding. That small hook is exactly what gang scheduling plugins use, as we will see in Part 4.</p><h3>Part 3: Where it breaks: four failure modes</h3><p>Put Part 1 and Part 2 together and the problem becomes obvious. Training needs <strong>all-or-nothing</strong>. The scheduler delivers <strong>one-at-a-time</strong>. That mismatch causes four distinct failures.</p><h4>Failure 1: Partial scheduling (the 7-of-8 problem)</h4><p>The cluster has 7 free GPUs. Your job asks for 8. The scheduler binds 7 pods and leaves the 8th Pending. The 7 running pods block in init_process_group() and burn GPU hours doing nothing. Meanwhile a smaller job that only needed 4 GPUs could have run and finished, but now it cannot get any.</p><h4>Failure 2: Resource deadlock (the one that hurts most)</h4><p>Two jobs, each needing 8 GPUs, on a cluster with 8 GPUs in total. Because their pods interleave in the queue, each job gets half.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Muvf3G6zHbBM9groEXZd4A.png" /></figure><p>This is a classic deadlock, and it ticks all the textbook conditions:</p><ul><li><strong>Mutual exclusion:</strong> a GPU can only be used by one pod.</li><li><strong>Hold and wait:</strong> each job holds 4 GPUs while waiting for 4 more.</li><li><strong>No preemption:</strong> Kubernetes will not take GPUs away from a running pod of equal priority.</li><li><strong>Circular wait:</strong> A waits for B’s GPUs, B waits for A’s.</li></ul><p>No one is “wrong” here. The scheduler made a perfectly reasonable choice for each pod. The deadlock lives <em>between</em> the decisions.</p><h4>Failure 3: Timeout-and-restart thrashing</h4><p>The stuck workers eventually hit the NCCL or rendezvous timeout and crash. The Job controller restarts them. If the scheduler again gives them a partial set, the cycle repeats. Logs fill with timeout errors that look like a networking bug, which sends engineers on a long hunt in the wrong direction.</p><h4>Failure 4: Autoscaler confusion</h4><p>With cluster autoscaling, a partially scheduled job may trigger scale-up for only the pending pods, one node at a time. The job is slow to start and you pay for the idle half while the rest arrives. A gang-aware stack can instead ask for the whole set of capacity at once.</p><h4>Reproducing the deadlock with plain YAML</h4><p>Here is what a typical distributed training job looks like with only built-in Kubernetes objects: an <strong>Indexed Job</strong> plus a <strong>headless Service</strong> so workers can find each other by DNS. We will run this exact pair in the hands-on lab later.</p><pre># job-a.yaml  (job-b.yaml is identical with &quot;a&quot; replaced by &quot;b&quot;)<br>apiVersion: v1<br>kind: Service<br>metadata:<br>  name: train-a              # headless service: gives each pod a stable DNS name<br>spec:<br>  clusterIP: None<br>  selector:<br>    job-name: train-a<br>---<br>apiVersion: batch/v1<br>kind: Job<br>metadata:<br>  name: train-a<br>spec:<br>  completionMode: Indexed    # each pod gets an index 0..7 -&gt; use it as the RANK<br>  completions: 8<br>  parallelism: 8             # we want all 8 workers running at the same time<br>  backoffLimit: 0<br>  template:<br>    spec:<br>      subdomain: train-a     # pod DNS: train-a-0.train-a, train-a-1.train-a, ...<br>      restartPolicy: Never<br>      containers:<br>      - name: worker<br>        image: busybox:1.36<br>        command: [&quot;sh&quot;, &quot;-c&quot;, &quot;echo rank=$JOB_COMPLETION_INDEX waiting for peers; sleep 3600&quot;]<br>        resources:<br>          requests:<br>            example.com/gpu: 1   # a fake GPU resource we advertise in the lab<br>          limits:<br>            example.com/gpu: 1</pre><p>How to read it:</p><ul><li>kind: Service with clusterIP: None creates a <strong>headless Service</strong>. It has no load-balancing IP; it just publishes DNS records so worker 3 can reach worker 0 at train-a-0.train-a. Real training uses this as the MASTER_ADDR.</li><li>completionMode: Indexed gives each pod a number in the env var JOB_COMPLETION_INDEX. Training scripts map it to RANK.</li><li>parallelism: 8 tells the Job controller to create 8 pods at once. Note: this asks for 8 pods to be <em>created</em>. It does <strong>not</strong> ask the scheduler to place them <em>together</em>. That gap is the whole problem.</li><li>example.com/gpu: 1 is an <strong>extended resource</strong>. On a real cluster this would be nvidia.com/gpu: 1. In the lab we advertise a fake one so you can reproduce GPU behaviour on a laptop.</li><li>The container just sleeps. In real life this is where torchrun train.py would block in init_process_group().</li></ul><p>Apply job-a.yaml and job-b.yaml at the same moment on a cluster with 8 fake GPUs, and you will usually see something like this:</p><pre>$ kubectl get pods -o wide<br>NAME              READY   STATUS    NODE<br>train-a-0-xxxxx   1/1     Running   worker-1<br>train-a-1-xxxxx   1/1     Running   worker-2<br>train-a-2-xxxxx   1/1     Running   worker-1<br>train-a-3-xxxxx   1/1     Running   worker-2<br>train-a-4-xxxxx   0/1     Pending   &lt;none&gt;<br>...<br>train-b-0-xxxxx   1/1     Running   worker-1<br>train-b-1-xxxxx   1/1     Running   worker-2<br>...<br>train-b-7-xxxxx   0/1     Pending   &lt;none&gt;</pre><p>The exact split varies from run to run (it depends on how pod creation interleaves), but any split other than 8/0 leaves both jobs unable to train. kubectl describe pod train-a-4-xxxxx will show the event 0/2 nodes are available: 2 Insufficient example.com/gpu. Kubernetes considers this a normal, healthy state. Your training team does not.</p><h3>Part 4: What gang scheduling actually is</h3><p><strong>Gang scheduling</strong> means a set of related pods is treated as one scheduling unit: the whole gang is placed together, or none of it is placed. The term comes from 1980s parallel computing, where operating systems learned to run all threads of a parallel program at the same moment on different CPUs, for exactly the same reason: the threads constantly wait on each other.</p><p>A real-world analogy: a restaurant does not seat half of a party of eight and tell the others to wait outside. The host either finds a table for eight, or the whole party waits in the lobby, <em>without</em> occupying any tables. The tables stay free for smaller groups in the meantime.</p><h4>The building blocks</h4><p>Every gang scheduling implementation, whatever its name, is built from the same few ideas:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kUSHtYCCSPAfvmv-u6N3wg.png" /></figure><p>Why is the minimum size configurable instead of always “all pods”? Some frameworks can start with fewer workers. Elastic training (for example torchrun with a range like --nnodes=4:8) can begin with 4 workers and grow to 8. For a classic fixed-size job, set the minimum equal to the total number of workers.</p><h4>The decision flow</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2pktZ8LB9YkSKQ06Ixze3A.png" /></figure><p>Walking through the diagram:</p><ol><li><strong>Quorum check.</strong> A controller (Job, JobSet, Volcano, your operator) creates the group object and its pods. The scheduler does nothing until enough pods exist. This prevents the scheduler from wasting effort on a gang whose members have not even been created yet.</li><li><strong>Tentative placement.</strong> The scheduler runs the normal Filter and Score steps for each member. Instead of binding, each placed pod <strong>waits at the Permit gate</strong> with its resources reserved.</li><li><strong>The verdict.</strong> If at least the minimum number of pods found a place, all of them are approved and bound together. The workers start at roughly the same moment, and init_process_group() succeeds quickly.</li><li><strong>The release.</strong> If not, nothing is bound. Reservations are undone and the GPUs go back to the pool, so other jobs can use them. The gang retries later when resources free up.</li></ol><p>Apply this to our deadlock. Job A’s 8 pods are placed tentatively. Job B’s pods find no room and get released <em>before</em> they ever hold anything. Job A runs. When it finishes, job B gets its turn. The cluster went from 0% useful work to 100%, without buying a single GPU.</p><h4>Two layers where “all-or-nothing” can live</h4><p>One more idea will make the tools in the next sections much easier to understand. All-or-nothing can be enforced at two different layers:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6l1UpffV09neEqrmaCNxNA.png" /></figure><ul><li><strong>Scheduling layer (where do the pods go?).</strong> The scheduler itself refuses to bind a partial gang. This is what the coscheduling plugin, Volcano, and the new native GangScheduling plugin do.</li><li><strong>Admission layer (when may the job start at all?).</strong> A job queue keeps the whole job <em>suspended</em> until there is quota for all of it, then lets it through. If the pods still do not all become Ready in time, the queue evicts the whole job and requeues it. This is what Kueue does.</li></ul><p>These layers are complementary. Many production platforms use Kueue for fair sharing between teams <em>and</em> a gang-aware scheduler for atomic placement.</p><p>Now let’s look at each option with real YAML.</p><h3>Part 5: Option 1: the Coscheduling plugin (scheduler-plugins)</h3><p>The <a href="https://scheduler-plugins.sigs.k8s.io/docs/plugins/coscheduling">Coscheduling plugin</a> lives in the kubernetes-sigs/scheduler-plugins project, maintained by SIG Scheduling. It is the smallest step away from the default scheduler: it runs the <strong>same</strong> kube-scheduler code, plus a plugin that hooks into the extension points we met in Part 2. It works on clusters older than v1.37.</p><h4>Step 1: Install it as a second scheduler</h4><p>The safest way to try it is to run it <em>next to</em> the default scheduler. Only pods that ask for it by name use it; everything else is untouched.</p><pre># Installs the scheduler-plugins scheduler + its PodGroup controller<br>helm install --repo https://scheduler-plugins.sigs.k8s.io scheduler-plugins scheduler-plugins \<br>  --namespace scheduler-plugins --create-namespace<br><br>kubectl get deploy -n scheduler-plugins<br># NAME                           READY<br># scheduler-plugins-controller   1/1    &lt;- keeps PodGroup status up to date<br># scheduler-plugins-scheduler    1/1    &lt;- the gang-aware scheduler</pre><p>Two components appear. The <strong>scheduler</strong> makes placement decisions. The <strong>controller</strong> watches pods and updates each PodGroup&#39;s status (how many members are scheduled, running, failed).</p><h4>Step 2: Understand the scheduler configuration</h4><p>The chart ships a KubeSchedulerConfiguration similar to this. You rarely edit it, but reading it shows exactly how the plugin works:</p><pre>apiVersion: kubescheduler.config.k8s.io/v1<br>kind: KubeSchedulerConfiguration<br>profiles:<br>- schedulerName: scheduler-plugins-scheduler   # pods opt in with this name<br>  plugins:<br>    multiPoint:<br>      enabled:<br>      - name: Coscheduling       # register the plugin at every point it implements<br>    queueSort:<br>      enabled:<br>      - name: Coscheduling       # replace the default queue ordering...<br>      disabled:<br>      - name: &quot;*&quot;                # ...only one queueSort plugin may be active</pre><p>What each hook does:</p><ul><li><strong>QueueSort</strong>: orders the queue by priority, then by the PodGroup’s creation time. Members of the same gang end up next to each other instead of interleaved with another job. This alone reduces the A/B/A/B split from Part 3.</li><li><strong>PreFilter</strong>: before trying a pod, checks that at least minMember pods of its group exist. If not, it rejects early (this is the quorum check).</li><li><strong>PostFilter</strong>: if one member cannot be placed, the plugin can reject the rest of its group’s waiting pods, so they release their reservations immediately instead of holding them until timeout.</li><li><strong>Permit</strong>: counts running pods plus pods waiting at Permit. If the count reaches minMember, every waiting member is allowed through to Bind. Otherwise the pod is told to <strong>wait</strong>, for up to scheduleTimeoutSeconds.</li><li><strong>Unreserve</strong>: when the wait times out, all waiting members are rejected and their resources released.</li></ul><h4>Step 3: Create a PodGroup and point your pods at it</h4><pre># podgroup-train-a.yaml<br>apiVersion: scheduling.x-k8s.io/v1alpha1<br>kind: PodGroup<br>metadata:<br>  name: train-a<br>spec:<br>  minMember: 8                 # all 8 workers, or none<br>  scheduleTimeoutSeconds: 60   # how long placed members wait at Permit for the rest<br>  # minResources:              # optional: total resources the gang needs; lets the<br>  #   example.com/gpu: 8       # plugin reject early when the cluster obviously can&#39;t fit it</pre><p>Then two small changes to the Job from Part 3:</p><pre># job-a.yaml (only the changed part of the pod template is shown)<br>  template:<br>    metadata:<br>      labels:<br>        scheduling.x-k8s.io/pod-group: train-a   # 1. join the gang<br>    spec:<br>      schedulerName: scheduler-plugins-scheduler # 2. use the gang-aware scheduler<br>      subdomain: train-a<br>      restartPolicy: Never<br>      containers:<br>      - name: worker<br>        image: busybox:1.36<br>        command: [&quot;sh&quot;, &quot;-c&quot;, &quot;echo rank=$JOB_COMPLETION_INDEX; sleep 3600&quot;]<br>        resources:<br>          limits:<br>            example.com/gpu: 1</pre><p>Explanation:</p><ul><li>The <strong>label</strong> scheduling.x-k8s.io/pod-group: train-a is how a pod says &quot;I belong to PodGroup train-a&quot;. The group name must match the PodGroup in the same namespace.</li><li>schedulerName routes the pod to the second scheduler. Without it, the default scheduler places the pod and the gang rules are silently ignored. This is the number one mistake people make.</li><li>minMember: 8 equals parallelism: 8. If you set minMember higher than the number of pods the Job creates, the gang can never be satisfied and stays Pending forever.</li></ul><p>Repeat for train-b and apply both at the same time. Now one job gets all 8 GPUs and runs; the other sits fully Pending with zero GPUs held:</p><pre>$ kubectl get podgroups<br>NAME      PHASE       MINMEMBER   RUNNING   AGE<br>train-a   Running     8           8         40s<br>train-b   Pending     8                     40s</pre><p><strong>Good fit for:</strong> clusters before v1.37, teams that want gang semantics with minimal new concepts, and existing Job / PyTorchJob / MPIJob workloads where you can add a label.</p><p><strong>Watch out for:</strong> you are running and upgrading a second scheduler. Two schedulers do not coordinate with each other, so avoid letting both place pods onto the same GPU nodes (use node labels or taints to give each its own pool).</p><h3>Part 6: Option 2: Volcano</h3><p><a href="https://volcano.sh/en/docs/vcjob/">Volcano</a> is a CNCF batch system built for AI, big data and HPC. Where the coscheduling plugin adds one feature to the normal scheduler, Volcano is a whole platform: its own scheduler, its own job type (vcjob), queues with weights and capacity, and job lifecycle policies. Gang scheduling is its core plugin, called gang.</p><p>Volcano installs three components: volcano-scheduler (placement, including gang), volcano-controllers (manages Jobs, Queues and PodGroups), and volcano-admission (a webhook that validates your YAML).</p><h4>A queue for the team</h4><pre>apiVersion: scheduling.volcano.sh/v1beta1<br>kind: Queue<br>metadata:<br>  name: research<br>spec:<br>  weight: 1              # share of the cluster relative to other queues<br>  reclaimable: true      # other queues may borrow idle capacity and give it back<br>  capability:<br>    nvidia.com/gpu: 16   # hard ceiling for this queue</pre><p>A Queue is a bucket of capacity. Jobs are admitted into a queue only if the queue can hold their <em>minimum</em> gang. This gives Volcano an admission layer as well as a scheduling layer.</p><h4>A PyTorch training job with gang semantics</h4><pre>apiVersion: batch.volcano.sh/v1alpha1<br>kind: Job<br>metadata:<br>  name: llm-finetune<br>spec:<br>  schedulerName: volcano     # 1. use the Volcano scheduler<br>  queue: research            # 2. which Queue this job is charged to<br>  minAvailable: 4            # 3. gang size: 1 master + 3 workers, all or nothing<br>  maxRetry: 3<br>  plugins:<br>    pytorch: [&quot;--master=master&quot;, &quot;--worker=worker&quot;, &quot;--port=23456&quot;]  # injects MASTER_ADDR, RANK, WORLD_SIZE<br>  policies:<br>  - event: PodEvicted        # 4. if any pod is evicted or fails...<br>    action: RestartJob       #    ...restart the WHOLE job, not just that pod<br>  - event: PodFailed<br>    action: RestartJob<br>  tasks:<br>  - name: master<br>    replicas: 1<br>    template:<br>      spec:<br>        restartPolicy: OnFailure<br>        containers:<br>        - name: trainer<br>          image: my-registry/llm-train:1.0<br>          command: [&quot;torchrun&quot;, &quot;train.py&quot;]<br>          resources:<br>            limits:<br>              nvidia.com/gpu: 1<br>  - name: worker<br>    replicas: 3<br>    template:<br>      spec:<br>        restartPolicy: OnFailure<br>        containers:<br>        - name: trainer<br>          image: my-registry/llm-train:1.0<br>          command: [&quot;torchrun&quot;, &quot;train.py&quot;]<br>          resources:<br>            limits:<br>              nvidia.com/gpu: 1</pre><p>The fields that matter:</p><ol><li>schedulerName: volcano hands these pods to Volcano instead of the default scheduler.</li><li>queue: research charges the job against the team&#39;s Queue.</li><li>minAvailable: 4 is the gang size. Volcano automatically creates a PodGroup behind the scenes with minMember: 4. The gang plugin will not bind any of the 4 pods until all 4 fit.</li><li>policies handle the <em>runtime</em> side of all-or-nothing. If one worker dies mid-training, the other three are useless (Part 1), so RestartJob tears down and restarts the whole gang. This is something the plain Job controller cannot express.</li><li>plugins.pytorch wires up the environment variables (MASTER_ADDR, WORLD_SIZE, RANK) so torchrun can rendezvous. Volcano also ships ssh and svc plugins for MPI-style jobs.</li><li>tasks lets one job contain different roles (master, workers, parameter servers) with different templates, something a single batch/v1 Job cannot do.</li></ol><p>Check it:</p><pre>$ kubectl get vcjob llm-finetune<br>NAME           STATUS    MINAVAILABLE   RUNNINGS   AGE<br>llm-finetune   Running   4              4          30s<br><br>$ kubectl get podgroup<br>NAME                                           STATUS    MINMEMBER   RUNNINGS<br>llm-finetune-6a1c...                           Running   4           4</pre><p>If the queue cannot fit 4 GPUs at once, the PodGroup stays in the Inqueue / Pending phase and no pod is bound.</p><p><strong>Good fit for:</strong> dedicated AI/HPC platforms, MPI and multi-role jobs, teams that want queues, fair share and lifecycle policies in one package. Recent releases also add gang-granularity preemption and topology-aware placement.</p><p><strong>Watch out for:</strong> Volcano is a second scheduler with its own concepts to learn and operate. Pods that do not set schedulerName: volcano are still placed by the default scheduler, so plan your node pools carefully.</p><h3>Part 7: Option 3: Kueue (all-or-nothing at admission)</h3><p><a href="https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/">Kueue</a> takes a different approach. It is <strong>not a scheduler</strong>. It is a job queueing controller that decides <em>when</em> a whole job is allowed to create pods. The default scheduler (or any other) still decides <em>where</em> those pods go.</p><p>The trick Kueue relies on is the suspend field of batch Jobs (and JobSet, RayJob, PyTorchJob and others). A suspended job exists but creates no pods. Kueue holds every job suspended until there is enough <strong>quota</strong> for the entire job, then flips suspend to false. Because quota is checked for the whole job at once, two 8-GPU jobs are never admitted against 8 GPUs of quota at the same time. Most of the deadlock disappears before the scheduler is involved.</p><h4>Step 1: Describe your capacity</h4><pre>apiVersion: kueue.x-k8s.io/v1beta2<br>kind: ResourceFlavor<br>metadata:<br>  name: gpu-a100            # a &quot;kind&quot; of node; one is enough if your GPUs are identical<br>---<br>apiVersion: kueue.x-k8s.io/v1beta2<br>kind: ClusterQueue<br>metadata:<br>  name: gpu-cluster-queue<br>spec:<br>  namespaceSelector: {}     # accept workloads from every namespace<br>  resourceGroups:<br>  - coveredResources: [&quot;cpu&quot;, &quot;memory&quot;, &quot;nvidia.com/gpu&quot;]<br>    flavors:<br>    - name: gpu-a100<br>      resources:<br>      - name: &quot;cpu&quot;<br>        nominalQuota: 64<br>      - name: &quot;memory&quot;<br>        nominalQuota: 512Gi<br>      - name: &quot;nvidia.com/gpu&quot;<br>        nominalQuota: 8     # the whole cluster has 8 GPUs<br>---<br>apiVersion: kueue.x-k8s.io/v1beta2<br>kind: LocalQueue<br>metadata:<br>  name: research<br>  namespace: ml-team<br>spec:<br>  clusterQueue: gpu-cluster-queue   # the namespaced &quot;front door&quot; users submit to</pre><ul><li>A <strong>ResourceFlavor</strong> names a type of hardware.</li><li>A <strong>ClusterQueue</strong> holds the quota: here, 8 GPUs in total. Several ClusterQueues can share unused quota through a <strong>cohort</strong>.</li><li>A <strong>LocalQueue</strong> is the per-namespace entry point. Users only need to know its name.</li></ul><h4>Step 2: Submit a JobSet to the queue</h4><p><a href="https://kubernetes.io/blog/2025/03/23/introducing-jobset/">JobSet</a> is the Kubernetes-native API for multi-role distributed training (a leader plus workers, each as its own Job, with shared DNS). Kueue admits a JobSet as one unit.</p><pre>apiVersion: jobset.x-k8s.io/v1alpha2<br>kind: JobSet<br>metadata:<br>  name: train-a<br>  namespace: ml-team<br>  labels:<br>    kueue.x-k8s.io/queue-name: research   # 1. send this workload to the LocalQueue<br>spec:<br>  failurePolicy:<br>    maxRestarts: 3                        # 2. a failed child Job restarts the whole JobSet<br>  replicatedJobs:<br>  - name: workers<br>    replicas: 1<br>    template:<br>      spec:<br>        parallelism: 8<br>        completions: 8<br>        completionMode: Indexed<br>        backoffLimit: 0<br>        template:<br>          spec:<br>            restartPolicy: Never<br>            containers:<br>            - name: trainer<br>              image: my-registry/llm-train:1.0<br>              command: [&quot;torchrun&quot;, &quot;train.py&quot;]<br>              resources:<br>                requests:<br>                  cpu: &quot;6&quot;<br>                  memory: 48Gi<br>                limits:<br>                  nvidia.com/gpu: 1</pre><ol><li>The kueue.x-k8s.io/queue-name <strong>label</strong> is the only change your users make. Kueue&#39;s webhook suspends the JobSet on creation.</li><li>JobSet’s failurePolicy restarts all child Jobs together when one fails, the runtime half of all-or-nothing.</li></ol><p>Kueue sums the requests (8 GPUs, 48 CPU, 384Gi), checks the ClusterQueue, and admits the JobSet only if all of it fits. A second 8-GPU JobSet waits in the queue with <strong>zero pods created</strong>.</p><h4>Step 3: Turn on waitForPodsReady</h4><p>Quota is not the same as physical placement. Quota might say “8 GPUs free” while those GPUs are fragmented across nodes, or a node is unhealthy, or another scheduler grabbed some. Kueue’s answer is a <strong>timeout-based all-or-nothing</strong> check, configured in Kueue’s controller configuration:</p><pre># part of Kueue&#39;s controller manager configuration (kueue-manager-config ConfigMap)<br>waitForPodsReady:<br>  enable: true<br>  timeout: 10m            # all pods must be Ready within 10 minutes of admission<br>  blockAdmission: true    # admit the next workload only after the current one is fully Ready<br>  requeuingStrategy:<br>    timestamp: Eviction   # requeued jobs go to the back of the queue<br>    backoffLimitCount: 5  # give up (deactivate) after 5 requeues<br>    backoffBaseSeconds: 60</pre><ul><li>timeout: if not every pod of an admitted job becomes Ready in time, Kueue <strong>evicts the whole job</strong>: it suspends it again (deleting its pods and freeing their GPUs) and puts it back in the queue.</li><li>blockAdmission: admits jobs <strong>one at a time</strong> until each is fully Ready. This is what removes the A/B race completely, at the cost of slower ramp-up when many jobs arrive at once.</li><li>requeuingStrategy: controls where an evicted job goes in the queue and how many times it may retry, with exponential backoff.</li></ul><p>Field names follow the <a href="https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/">Kueue documentation</a>; check the configuration reference for the exact shape in your Kueue version.</p><p><strong>Good fit for:</strong> shared clusters with several teams, quotas, priorities, fair sharing, and multi-cluster dispatch (MultiKueue). It integrates with Job, JobSet, Kubeflow training jobs, RayJob, LeaderWorkerSet and plain pod groups.</p><p><strong>Watch out for:</strong> on its own, Kueue gives all-or-nothing <em>by timeout</em>, not by atomic placement: for a few minutes, a partially placed job can still hold GPUs before Kueue evicts it. Pair Kueue with a gang-aware scheduler (the native API in the next part, or coscheduling) when that window is too expensive.</p><h3>Part 8: Option 4: native gang scheduling with the Workload and PodGroup APIs</h3><p>For years, gang scheduling on Kubernetes meant installing something extra. That is changing. SIG Scheduling has been building <strong>Workload-Aware Scheduling</strong> directly into kube-scheduler, and in <strong>Kubernetes v1.37</strong> (released August 2026) the Workload and PodGroup APIs and gang scheduling <a href="https://kubernetes.io/blog/2026/09/08/kubernetes-v1-37-advancing-workload-aware-scheduling/">graduated to Beta</a>.</p><h4>A quick history (so old blog posts don’t confuse you)</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/577/1*jP_Q5_tBO_wIkwUHteIOug.png" /></figure><p>If you find YAML online with spec.workloadRef on a pod, it targets the v1.35 alpha and will not work as-is on v1.37.</p><h4>The three objects</h4><p>The design separates long-lived <em>policy</em> from per-run <em>state</em>, the same way a Deployment (policy) relates to ReplicaSets (state):</p><ul><li><strong>Workload</strong>: a template that says “jobs of this type have a group called worker that must be gang-scheduled with at least N pods&quot;.</li><li><strong>PodGroup</strong>: one concrete running instance of that group. It carries the policy (copied from the template) and the scheduling status.</li><li><strong>Pod</strong>: points at its PodGroup through spec.schedulingGroup.podGroupName.</li></ul><p>In day-to-day use a <strong>controller</strong> creates the Workload and PodGroup for you. As of v1.37 the built-in Job controller is the first to do this (Alpha), and out-of-tree controllers can use the new workloadbuilder Go library. Writing them by hand, as below, is the best way to see how it works.</p><h4>Enable it</h4><p>Beta features are usually on by default, but this one is <strong>disabled by default</strong> in v1.37. You need the GenericWorkload feature gate on every relevant component and the scheduling.k8s.io/v1beta1 API group served:</p><pre>kube-apiserver           --feature-gates=GenericWorkload=true --runtime-config=scheduling.k8s.io/v1beta1=true<br>kube-scheduler           --feature-gates=GenericWorkload=true<br>kube-controller-manager  --feature-gates=GenericWorkload=true   # needed for controller integrations</pre><p>On a managed cloud cluster, check whether your provider lets you set feature gates on the control plane; many do not for Beta features. The hands-on lab shows how to do it on kind.</p><h4>Gang-scheduling 8 training workers natively</h4><pre>apiVersion: scheduling.k8s.io/v1beta1<br>kind: Workload<br>metadata:<br>  name: llm-training             # long-lived policy: &quot;how jobs of this kind are scheduled&quot;<br>spec:<br>  podGroupTemplates:<br>  - name: worker<br>    schedulingPolicy:<br>      gang:<br>        minCount: 8              # all 8 workers must be placeable together<br>---<br>apiVersion: scheduling.k8s.io/v1beta1<br>kind: PodGroup<br>metadata:<br>  name: train-a-workers          # one concrete run<br>spec:<br>  podGroupTemplateRef:           # optional link back to the template (for tooling)<br>    workload:<br>      workloadName: llm-training<br>      podGroupTemplateName: worker<br>  schedulingPolicy:<br>    gang:<br>      minCount: 8<br>---<br>apiVersion: v1<br>kind: Pod<br>metadata:<br>  name: train-a-worker-0         # ...repeat for worker-1 .. worker-7<br>spec:<br>  schedulingGroup:<br>    podGroupName: train-a-workers   # join the gang<br>  containers:<br>  - name: trainer<br>    image: my-registry/llm-train:1.0<br>    resources:<br>      limits:<br>        nvidia.com/gpu: 1</pre><p>Piece by piece:</p><ul><li>Workload.spec.podGroupTemplates[].schedulingPolicy chooses gang (all-or-nothing) or basic (normal pod-by-pod scheduling, but grouped for observability).</li><li>gang.minCount is the native equivalent of minMember / minAvailable.</li><li>PodGroup.spec.schedulingPolicy holds the policy that actually applies to this run. When a controller creates the PodGroup it copies this from the template; for a standalone PodGroup you set it yourself.</li><li>Pod.spec.schedulingGroup.podGroupName is the membership link. <strong>No </strong><strong>schedulerName is needed</strong>: the default kube-scheduler does the gang work.</li></ul><h4>What the scheduler does with it</h4><p>According to the <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/gang-scheduling/">gang scheduling docs</a>, the built-in GangScheduling plugin follows the flow from Part 4 almost exactly:</p><ol><li><strong>PreEnqueue:</strong> the pods are held out of the scheduling queue until the PodGroup exists and at least minCount pods have been created.</li><li><strong>PodGroup scheduling cycle:</strong> the scheduler then evaluates all unscheduled pods of the group in one atomic decision, checking via a PlacementFeasible hook whether minCount can still be reached.</li><li><strong>Bind or release:</strong> if at least minCount pods found a place, they are bound. If not, none are; the group goes to the unschedulable queue and other workloads can use the capacity meanwhile.</li></ol><p>The scheduler reports the result in status.conditions on the PodGroup:</p><pre>kubectl get podgroups<br>kubectl describe podgroup train-a-workers   # look for the PodGroupInitiallyScheduled condition</pre><p>One subtlety from the docs: PodGroupInitiallyScheduled only describes the <strong>initial</strong> placement. If a running worker is later evicted, the condition does not flip back, and the scheduler will only add replacement pods if the total can reach minCount again. For runtime failures you still want a controller policy that restarts the whole gang (JobSet failurePolicy, Volcano RestartJob, or Kueue eviction).</p><h4>Related v1.37 features worth knowing</h4><ul><li><strong>Workload-aware preemption (Beta):</strong> when a high-priority gang needs room, the scheduler can evict a <em>whole</em> lower-priority PodGroup as a unit (controlled by the PodGroup’s disruptionMode and priorityClassName) instead of killing random single pods that would break several jobs at once.</li><li><strong>DRA ResourceClaims for PodGroups (Beta):</strong> a PodGroup can request devices through spec.resourceClaims and share a claim across its pods.</li><li><strong>CompositePodGroup (Alpha):</strong> gangs of gangs, for example “schedule at least 2 complete replicas of this 8-pod training group”, which maps naturally to JobSet and LeaderWorkerSet.</li></ul><p><strong>Good fit for:</strong> clusters on v1.37+ where you control feature gates, and teams that want gang semantics without operating a second scheduler.</p><p><strong>Watch out for:</strong> it is Beta and off by default, the controller ecosystem (JobSet, Kubeflow, Kueue integration) is still adopting it, and managed providers may not expose the gate yet.</p><h3>Part 9: Side-by-side comparison and how to choose</h3><p>All four options solve the same core problem. They differ in <em>where</em> they act, <em>how much</em> they bring along, and <em>how much</em> you have to operate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*uKHffgeJb9_mNrWNdXkudg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HwSiFzVAWjxITsgRGMIqnQ.png" /></figure><p>A few practical combinations that work well:</p><ul><li><strong>Shared enterprise GPU cluster:</strong> Kueue for quotas and fair sharing between teams, plus the native PodGroup API (on v1.37+) or the coscheduling plugin for atomic placement.</li><li><strong>Dedicated research / HPC cluster:</strong> Volcano on its own, using its Queues for sharing and RestartJob policies for runtime failures.</li><li><strong>Small team, one cluster, older Kubernetes:</strong> the coscheduling plugin. One Helm install, one label, one PodGroup per job.</li><li><strong>Greenfield cluster on v1.37+:</strong> start with the native API to avoid running a second scheduler, and keep an eye on the Job controller and JobSet integrations as they mature.</li></ul><p>Whichever you choose, <strong>never run two gang-aware schedulers against the same GPU nodes</strong>. Each scheduler keeps its own view of free resources, and they will race each other into exactly the kind of partial placement you are trying to avoid.</p><h3>Part 10: Hands-on lab: break it, then fix it, on your laptop</h3><p>You do not need GPUs to see gang scheduling in action. We will create a local cluster with kind, advertise <strong>fake GPUs</strong> as an extended resource, reproduce the deadlock, and then fix it with the native PodGroup API.</p><p><strong>You need:</strong> Docker, <a href="https://kind.sigs.k8s.io/">kind</a>, and kubectl, with a kind node image for Kubernetes v1.37 or newer. (On an older version, skip to the coscheduling variant at the end.)</p><h4>Step 1: Create a 2-worker cluster with the gang feature gate on</h4><pre># kind-gang.yaml<br>kind: Cluster<br>apiVersion: kind.x-k8s.io/v1alpha4<br>name: gang-lab<br>featureGates:<br>  GenericWorkload: true                # turns on Workload/PodGroup + gang scheduling in every component<br>runtimeConfig:<br>  &quot;scheduling.k8s.io/v1beta1&quot;: &quot;true&quot;  # serve the Beta API group<br>nodes:<br>- role: control-plane<br>- role: worker<br>- role: worker</pre><pre>kind create cluster --config kind-gang.yaml<br>kubectl api-resources --api-group=scheduling.k8s.io<br># should list: podgroups, workloads, priorityclasses</pre><p>The top-level featureGates and runtimeConfig in a kind config are applied to every control-plane component, which is exactly what the docs ask for. If podgroups is missing from the output, the gate or API group did not take effect.</p><h4>Step 2: Give each worker node 4 fake GPUs</h4><p>Kubernetes lets you advertise any countable <strong>extended resource</strong> on a node by patching the node’s status. The scheduler then treats it exactly like nvidia.com/gpu.</p><pre>for node in gang-lab-worker gang-lab-worker2; do<br>  kubectl patch node &quot;$node&quot; --subresource=status --type=json \<br>    -p &#39;[{&quot;op&quot;:&quot;add&quot;,&quot;path&quot;:&quot;/status/capacity/example.com~1gpu&quot;,&quot;value&quot;:&quot;4&quot;}]&#39;<br>done<br><br>kubectl get nodes -o custom-columns=NAME:.metadata.name,GPU:.status.allocatable.example\\.com/gpu<br># gang-lab-control-plane   &lt;none&gt;<br># gang-lab-worker          4<br># gang-lab-worker2         4</pre><p>The ~1 in the path is JSON-Patch escaping for /, so example.com~1gpu means the key example.com/gpu. We now have a cluster with <strong>8 &quot;GPUs&quot;</strong> in total.</p><h4>Step 3: Reproduce the deadlock</h4><p>Save the job-a.yaml from Part 3, create job-b.yaml by replacing every train-a with train-b, and submit both together:</p><pre>sed &#39;s/train-a/train-b/g&#39; job-a.yaml &gt; job-b.yaml<br>kubectl apply -f job-a.yaml -f job-b.yaml<br>sleep 10<br>kubectl get pods -l &#39;job-name in (train-a,train-b)&#39; \<br>  -o custom-columns=POD:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName | sort</pre><p>In most runs you will see both jobs with some Running and some Pending pods, and 8 GPUs fully allocated. (If you get lucky and one job takes all 8, delete both jobs and run it again; the interleaving is a race, which is part of why this bug is so hard to catch in testing.) Clean up before the fix:</p><pre>kubectl delete -f job-a.yaml -f job-b.yaml</pre><h4>Step 4: Fix it with native gang scheduling</h4><p>This script creates, for each job, one PodGroup with minCount: 8 and 8 worker pods that reference it:</p><pre>#!/usr/bin/env bash<br># gang-lab.sh<br>set -euo pipefail<br><br>for job in a b; do<br>  # one PodGroup per training run: all 8 or nothing<br>  kubectl apply -f - &lt;&lt;EOF<br>apiVersion: scheduling.k8s.io/v1beta1<br>kind: PodGroup<br>metadata:<br>  name: train-${job}<br>spec:<br>  schedulingPolicy:<br>    gang:<br>      minCount: 8<br>EOF<br>  # 8 workers that join that PodGroup<br>  for i in $(seq 0 7); do<br>    kubectl apply -f - &lt;&lt;EOF<br>apiVersion: v1<br>kind: Pod<br>metadata:<br>  name: train-${job}-${i}<br>  labels:<br>    app: gang-lab<br>    run: train-${job}<br>spec:<br>  schedulingGroup:<br>    podGroupName: train-${job}<br>  restartPolicy: Never<br>  containers:<br>  - name: worker<br>    image: busybox:1.36<br>    command: [&quot;sh&quot;, &quot;-c&quot;, &quot;echo rank=${i}; sleep 3600&quot;]<br>    resources:<br>      limits:<br>        example.com/gpu: 1<br>EOF<br>  done<br>done</pre><p>What the script is doing:</p><ul><li>set -euo pipefail stops the script on the first error, so you never end up with a half-created gang by accident.</li><li>The outer loop runs twice, once for job a and once for job b, so we recreate the same competition as before.</li><li>kubectl apply -f - &lt;&lt;EOF ... EOF feeds the YAML between the markers straight into kubectl. The shell substitutes ${job} and ${i} before kubectl sees it.</li><li>Each pod sets spec.schedulingGroup.podGroupName, which is all it takes to join the gang. There is no schedulerName: the default scheduler handles it.</li></ul><p>Run it and watch:</p><pre>chmod +x gang-lab.sh &amp;&amp; ./gang-lab.sh<br>kubectl get pods -l app=gang-lab -o custom-columns=POD:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName | sort<br>kubectl get podgroups</pre><p>The expected result: one run has <strong>8 Running</strong> pods spread across both nodes, and the other has <strong>8 Pending</strong> pods holding <strong>zero</strong> GPUs. kubectl describe podgroup train-b should show that the group could not be placed as a whole. Now free the GPUs and watch the second gang start as a unit:</p><pre>kubectl delete pods -l run=train-a     # &quot;job A finished&quot;<br>kubectl get pods -l run=train-b -w     # all 8 move to Running together</pre><p>That is the whole idea of this post, in one terminal window: the cluster went from “both jobs stuck forever” to “one runs, then the other runs”, with the same 8 GPUs.</p><h4>Variant: on a cluster older than v1.37</h4><p>Use the coscheduling plugin from Part 5 instead. Install it with Helm, create PodGroup objects in scheduling.x-k8s.io/v1alpha1 with minMember: 8, and add the scheduling.x-k8s.io/pod-group label plus schedulerName: scheduler-plugins-scheduler to the Job&#39;s pod template. The observable result is the same.</p><h4>Clean up</h4><pre>kind delete cluster --name gang-lab</pre><h3>Part 11: Production tips and common pitfalls</h3><p>Gang scheduling fixes the core problem, but a few details decide whether it works smoothly in production.</p><h4>Get the numbers right</h4><ul><li><strong>Set the gang size equal to the real number of workers</strong> for fixed-size training. minMember / minAvailable / minCount larger than the pods your controller creates means the gang waits forever. Smaller than the real need brings back partial starts.</li><li><strong>Use a smaller minimum only for elastic jobs</strong> that genuinely can start with fewer workers (for example torchrun --nnodes=4:8).</li><li><strong>Request whole GPUs and realistic CPU/memory.</strong> The gang only fits if every member fits, so an oversized memory request on one pod can block the entire group.</li></ul><h4>Tune the timeouts</h4><ul><li><strong>Placement wait</strong> (scheduleTimeoutSeconds for coscheduling): long enough for the controller to create all pods, short enough that a hopeless gang releases its reservations quickly. Tens of seconds to a few minutes is typical.</li><li><strong>Ready timeout</strong> (Kueue waitForPodsReady.timeout): must cover image pulls. Multi-gigabyte training images on cold nodes can take several minutes, so pre-pull images or use a node-local cache, or the timeout will evict healthy jobs.</li><li><strong>Framework timeouts</strong> (NCCL, rendezvous): with gang scheduling in place, a long rendezvous timeout is no longer your safety net. Keep it moderate so genuine failures surface quickly.</li></ul><h4>Plan for failures after scheduling</h4><p>Gang scheduling guarantees the <strong>start</strong>, not the <strong>life</strong> of a job. If one worker dies after an hour, the rest stall just like in Part 1. Pair gang placement with a whole-gang restart policy: JobSet failurePolicy, Volcano policies with RestartJob, or Kueue eviction and requeue. And <strong>checkpoint regularly</strong> so a restart resumes from the last checkpoint instead of from zero.</p><h4>Watch out for these mistakes</h4><ul><li><strong>Forgetting </strong><strong>schedulerName</strong> with coscheduling or Volcano. The pod is silently placed by the default scheduler and the gang rules never apply.</li><li><strong>Two gang-aware schedulers on the same nodes.</strong> Give each scheduler its own node pool using labels and taints.</li><li><strong>Mixed priorities inside one gang.</strong> Keep all members at the same PriorityClass; mixed priorities lead to surprising ordering and preemption behaviour.</li><li><strong>Fragmentation.</strong> 8 free GPUs spread as 1 per node across 8 nodes may still fail a gang that needs fast interconnect. Look at topology-aware scheduling (Kueue TAS, Volcano network topology, native topology-aware scheduling) once basic gang scheduling works.</li><li><strong>Autoscaler mismatch.</strong> Scale-up should be requested for the whole gang. Kueue’s ProvisioningRequest integration and gang-aware autoscaling work exist for exactly this reason.</li></ul><h4>Observe it</h4><p>A handful of commands answer most “why is my job not running?” questions:</p><pre>kubectl get podgroups -A                     # coscheduling, Volcano or native: gang status<br>kubectl describe podgroup &lt;name&gt;             # conditions and scheduling events<br>kubectl get events --field-selector reason=FailedScheduling<br>kubectl get workloads.kueue.x-k8s.io -A      # Kueue: admitted, pending, evicted<br>kubectl get clusterqueues                    # Kueue: quota usage per queue</pre><p>On dashboards, track <strong>time from submission to all-workers-Running</strong> per job, and the <strong>share of GPU hours held by pods whose job is not fully running</strong>. That second number is the direct cost of missing gang scheduling, and it should drop to near zero once it is in place.</p><h3>Conclusion</h3><p>The default Kubernetes scheduler is not broken. It was designed for independent replicas, and it makes a sensible decision for every single pod. Distributed training breaks that assumption: its workers are one program running in many places, and they are only useful together. Pod-by-pod placement then leads to partial starts, idle GPUs, timeouts, and full deadlocks.</p><p>Gang scheduling closes the gap with one rule: <strong>place the whole group, or place nothing</strong>. You can get it today with the coscheduling plugin, Volcano, or Kueue’s admission-level all-or-nothing, and since Kubernetes v1.37 it is a Beta feature of the default scheduler itself through the Workload and PodGroup APIs. Pick the option that matches your cluster version and how many teams share it, run the lab once to see the difference with your own eyes, and your GPUs will spend their time training models instead of waiting for teammates.</p><h3>Sources</h3><ul><li>Kubernetes docs: <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/gang-scheduling/">Gang Scheduling</a>, <a href="https://kubernetes.io/docs/concepts/workloads/podgroup-api/">PodGroup API</a>, <a href="https://kubernetes.io/docs/concepts/workloads/workload-api/policies">PodGroup scheduling policies</a>, <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/scheduling-framework/">Scheduling Framework</a></li><li>Kubernetes blog: <a href="https://kubernetes.io/blog/2026/09/08/kubernetes-v1-37-advancing-workload-aware-scheduling/">v1.37: Advancing Workload-Aware Scheduling</a>, <a href="https://kubernetes.io/blog/2026/08/26/kubernetes-v1-37-release/">v1.37 release announcement</a>, <a href="https://kubernetes.io/blog/2025/03/23/introducing-jobset/">Introducing JobSet</a></li><li>Enhancement proposals: <a href="https://www.kubernetes.dev/resources/keps/4671/">KEP-4671 Gang Scheduling</a>, <a href="https://www.kubernetes.dev/resources/keps/5832/">KEP-5832 Decouple PodGroup</a>, <a href="https://www.kubernetes.dev/resources/keps/5710/">KEP-5710 Workload-aware preemption</a></li><li>scheduler-plugins: <a href="https://scheduler-plugins.sigs.k8s.io/docs/plugins/coscheduling">Coscheduling plugin</a>, <a href="https://github.com/kubernetes-sigs/scheduler-plugins/blob/master/kep/42-podgroup-coscheduling/README.md">PodGroup coscheduling KEP</a></li><li>Volcano: <a href="https://volcano.sh/en/docs/vcjob/">VolcanoJob</a>, <a href="https://volcano.sh/docs/Scheduler/Plugins/gang">Gang plugin</a></li><li>Kueue: <a href="https://kueue.sigs.k8s.io/docs/tasks/manage/setup_wait_for_pods_ready/">All-or-nothing with ready Pods</a>, <a href="https://kueue.sigs.k8s.io/docs/reference/kueue.v1beta2/">v1beta2 API reference</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fd8e7fd5f9ae" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[React’s Independent Foundation: What Leaving Meta’s Umbrella Means for API Stability]]></title>
            <link>https://medium.com/@vasanthank29/reacts-independent-foundation-what-leaving-meta-s-umbrella-means-for-api-stability-6f2efe325231?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/6f2efe325231</guid>
            <category><![CDATA[react]]></category>
            <category><![CDATA[frontend-development]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[open-source]]></category>
            <category><![CDATA[javascript]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Fri, 23 Oct 2026 13:01:03 GMT</pubDate>
            <atom:updated>2026-10-23T13:01:03.476Z</atom:updated>
            <content:encoded><![CDATA[<p><em>React no longer belongs to Meta. Here is what that really means for the code you write every day — explained from the ground up, with diagrams and examples.</em></p><h3>Why this matters to you, even if you only write components</h3><p>On February 24, 2026, React, React Native and JSX officially stopped being Meta products. They now belong to the <strong>React Foundation</strong>, an independent non-profit hosted by the Linux Foundation — the same organization that looks after Linux, Kubernetes and Node.js.</p><p>Most headlines treated this as corporate news. But if you write React for a living, the real question is much more practical:</p><blockquote><em>Will the hooks, components and APIs I depend on keep working the way they do today?</em></blockquote><p>That question is about <strong>API stability</strong>. It is the quiet promise that useState will still behave like useState next year, that an upgrade from 19.2.5 to 19.2.6 will not break your login page, and that if something <em>is</em> going away, you will be warned long before it disappears.</p><p>In this article we will walk through it step by step:</p><ol><li>What actually happened, and when.</li><li>What “API” and “API stability” mean in React — with code, for complete beginners.</li><li>How the new two-layer governance model works, and why it was designed that way.</li><li>How a React API travels from an idea to a stable feature (release channels).</li><li>How deprecations work in practice, using real React 18 → 19 examples.</li><li>What changes, what stays the same, and the honest risks.</li><li>Defensive coding patterns that protect your app no matter who governs React.</li><li>A checklist your team can use today.</li></ol><p>You do not need any background in open-source governance. If you know what a React component is, you can follow along.</p><h3>Part 1 — What actually happened</h3><p>React was born inside Facebook around 2011 to solve a hard problem: building large interfaces where data keeps changing. It was open-sourced in 2013, and for over a decade Meta employed most of the core team, ran the infrastructure and owned the trademark.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*x_3URLeRqZOJWCJykdtZYg.png" /></figure><p>The change happened in two steps:</p><ul><li><strong>October 7, 2025 — the announcement.</strong> At React Conf 2025, the React team announced plans to move React and React Native from Meta into a new React Foundation, with a separate, independent technical governance structure. Their reasoning was simple: React had outgrown any single company.</li><li><strong>February 24, 2026 — the launch.</strong> The Linux Foundation officially launched the React Foundation. React, React Native and supporting projects like JSX are now owned by the foundation, with LF Projects, LLC hosting them under neutral governance.</li></ul><p>A few facts worth remembering:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/606/1*ORdwhO_xFky5wf9sg8mqXw.png" /></figure><h4>The part people miss</h4><p>Notice what did <strong>not</strong> happen. No code was rewritten. No API was renamed. Your package.json still says &quot;react&quot;: &quot;^19.x&quot;, and npm install react still pulls the same package from the same registry.</p><p>What changed is <em>who decides</em> — and, over the long run, who decides is exactly what determines how stable an API stays. To see why, we first need to be precise about what an API is.</p><h3>Part 2 — A beginner’s primer: what is “API stability” in React?</h3><h4>What is an API?</h4><p>An <strong>API</strong> (Application Programming Interface) is a <em>contract</em> between two pieces of code. One side promises: “If you call me this way, I will behave this way.” The other side relies on that promise.</p><p>For React, the API is everything you import and use: useState, useEffect, createRoot, &lt;Suspense&gt;, the props a component accepts, even the rules for how JSX turns into function calls.</p><pre>import { useState } from &quot;react&quot;;<br><br>function Counter() {<br>  // CONTRACT: useState(initialValue) returns [currentValue, setterFunction]<br>  const [count, setCount] = useState(0);<br>  return (<br>    &lt;button onClick={() =&gt; setCount(count + 1)}&gt;<br>      Clicked {count} times<br>    &lt;/button&gt;<br>  );<br>}</pre><p><strong>What is happening here, line by line:</strong></p><ul><li>import { useState } from &quot;react&quot; — you are depending on the React package to export something named useState. If a future version renamed it, this line would fail.</li><li>useState(0) — the contract says: give me an initial value, and I return an array with two items. The first is the current value; the second is a function that updates it and asks React to re-render.</li><li>setCount(count + 1) — the contract also says that calling the setter schedules a re-render with the new value. Your whole UI logic assumes this behavior.</li></ul><p>Three things make up the contract: the <strong>name</strong> (useState), the <strong>shape</strong> (what goes in, what comes out) and the <strong>behavior</strong> (what happens when you call it). Change any one, and code that depends on it can break.</p><h4>What is API stability?</h4><p><strong>API stability</strong> is the promise that this contract will not change without warning. A stable API lets you upgrade React for bug fixes and security patches without rewriting your app.</p><p>Think of it like electrical sockets. You can buy a new lamp from any brand and plug it in, because the socket shape is stable. If sockets changed shape every year, every upgrade would mean rewiring the house.</p><h4>How React communicates stability: Semantic Versioning</h4><p>React uses <strong>Semantic Versioning (semver)</strong>. Every version number has three parts, and each part is a message to you:</p><pre>19  .  2  .  6<br>   │      │     │<br>   │      │     └── PATCH: bug fixes only. Safe to upgrade.<br>   │      └──────── MINOR: new features added. Nothing removed.<br>   └─────────────── MAJOR: breaking changes are allowed here.</pre><p>A real example: in May 2026, React shipped 19.2.6 along with backports 19.1.7 and 19.0.6. These were patch releases focused on hardening React Server Components — fixes delivered to three release lines at once, so teams on older 19.x lines got them too without a forced feature upgrade.</p><p>Your package.json tells npm how much change you accept:</p><pre>{<br>  &quot;dependencies&quot;: {<br>    &quot;react&quot;: &quot;^19.2.6&quot;,<br>    &quot;react-dom&quot;: &quot;~19.2.6&quot;<br>  }<br>}</pre><p><strong>What the symbols mean:</strong></p><ul><li>^19.2.6 (caret) — accept any <strong>minor or patch</strong> update inside 19.x (19.3.0 is OK; 20.0.0 is not). You trust React&#39;s promise that minors never break you.</li><li>~19.2.6 (tilde) — accept <strong>only patch</strong> updates (19.2.7 is OK; 19.3.0 is not). More cautious.</li><li>19.2.6 (no symbol) — exact version. Maximum control; you upgrade manually.</li></ul><p>Notice the important point: <strong>semver is only a promise, not a law of physics.</strong> It works only because the people who maintain React choose to honor it. That is exactly why the question “who maintains React now?” matters so much for stability.</p><h3>Part 3 — The two-layer governance model</h3><p>The most important design decision in the whole transition is this: <strong>the people who fund React are not the people who decide React’s APIs.</strong> The React team stated that technical governance will always stay independent from the foundation board, with technical direction set by those who contribute to and maintain React.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*sA-DpyiAJDVxZU8bLIv5Hw.png" /></figure><h4>Layer 1: The React Foundation board (the “business” layer)</h4><p>The board has one representative from each member company. Its job is operational: owning the trademark and repositories, running CI and infrastructure, organizing React Conf and funding ecosystem programs. Think of it as the landlord who keeps the building running.</p><h4>Layer 2: Technical governance (the “engineering” layer)</h4><p>This layer decides what code ships: the roadmap, new APIs, breaking changes and deprecation timelines. Right now, a <strong>provisional leadership council</strong> is defining the permanent structure. Many observers expect something similar to the technical steering committees used by Node.js or Kubernetes, but as of this writing the final shape has not been published — so treat any specific prediction with caution.</p><h4>Why separate them? A thought experiment</h4><p>Imagine a single company that both pays for React and controls its APIs. That company builds a hosting product. A new API that favors its product would be tempting to rush through — and an API it does not need might be quietly deprioritized.</p><p>This concern was real, not hypothetical. When the foundation was first announced in 2025, reporting noted that the 21-member React core team included 14 Meta employees and 5 Vercel employees, and parts of the community had voiced unease about vendor influence over features like Server Components.</p><p>We can model the separation in code. Below is a toy model — not real React internals — showing the rule the two-layer design enforces:</p><pre>// A toy model of the governance &quot;firewall&quot; — for illustration only.<br>const board = {<br>  role: &quot;operations&quot;,<br>  can: [&quot;fund&quot;, &quot;runConference&quot;, &quot;manageTrademark&quot;, &quot;hostInfrastructure&quot;],<br>};<br><br>const technicalCouncil = {<br>  role: &quot;engineering&quot;,<br>  can: [&quot;approveRFC&quot;, &quot;shipAPI&quot;, &quot;deprecateAPI&quot;, &quot;scheduleBreakingChange&quot;],<br>};<br>function canPerform(body, action) {<br>  return body.can.includes(action);<br>}<br>console.log(canPerform(board, &quot;deprecateAPI&quot;));            // false<br>console.log(canPerform(technicalCouncil, &quot;deprecateAPI&quot;)); // true</pre><p><strong>Explanation:</strong> each governing body is an object with a list of allowed actions. canPerform simply checks whether an action is in that list. The board&#39;s list contains only operational powers, so asking it to deprecate an API returns false. That is the whole idea of the firewall: <em>no amount of sponsorship money adds </em><em>deprecateAPI to the board&#39;s list.</em></p><h4>What this means for stability</h4><p>Under single-company ownership, stability depended on one company’s priorities. Under the foundation, it depends on a documented process run by maintainers from many organizations. Processes are slower to change than priorities — and for API stability, slow and predictable is exactly what you want.</p><h3>Part 4 — How a React API travels from idea to stable</h3><p>New React APIs do not jump straight into your app. They move through <strong>release channels</strong>, and each channel carries a different level of promise. This pipeline existed under Meta and continues under the foundation — it is the main machinery that protects stability.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*0Tyx2tg9ofMF9LxNQeAwjA.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/992/1*9ULl-mdzAeqNsQ9NjHIeEw.png" /></figure><h4>A real journey: useEffectEvent</h4><p>useEffectEvent is a great example of patience in API design. It sat in experimental builds for a long time while the team refined its shape, and only became stable in <strong>React 19.2</strong> (October 2025). Here is the problem it solves.</p><p><strong>The problem — an Effect that re-runs too often:</strong></p><pre>import { useEffect } from &quot;react&quot;;<br><br>function ChatRoom({ roomId, theme }) {<br>  useEffect(() =&gt; {<br>    const connection = createConnection(roomId);<br>    connection.on(&quot;connected&quot;, () =&gt; {<br>      showNotification(&quot;Connected!&quot;, theme); // reads theme<br>    });<br>    connection.connect();<br>    return () =&gt; connection.disconnect();<br>  }, [roomId, theme]); // theme is a dependency, so...<br>}</pre><p><strong>Why this is a problem:</strong> the Effect reads theme, so the rules of hooks require theme in the dependency array. But that means switching from light to dark mode <em>disconnects and reconnects the chat</em>. The connection should depend only on roomId; the theme is just something we want to <em>read</em> at the moment the event fires.</p><p><strong>The fix — separate the “event” from the “effect”:</strong></p><pre>import { useEffect, useEffectEvent } from &quot;react&quot;;<br><br>function ChatRoom({ roomId, theme }) {<br>  // An Effect Event always sees the latest props, but is NOT reactive.<br>  const onConnected = useEffectEvent(() =&gt; {<br>    showNotification(&quot;Connected!&quot;, theme);<br>  });<br>  useEffect(() =&gt; {<br>    const connection = createConnection(roomId);<br>    connection.on(&quot;connected&quot;, () =&gt; onConnected());<br>    connection.connect();<br>    return () =&gt; connection.disconnect();<br>  }, [roomId]); // only roomId - theme changes no longer reconnect<br>}</pre><p><strong>Explanation:</strong> useEffectEvent wraps a function so that it always reads the <em>latest</em> values of props and state, but the function itself never counts as a dependency. The Effect now re-runs only when roomId changes. Changing the theme no longer tears down the connection.</p><p><strong>Why this story matters for stability:</strong> if this hook had been rushed to stable early, its first design might have been wrong — and fixing a stable API means a breaking change. Keeping it in experimental until the design was proven is what made the final API trustworthy. The foundation inherits this same channel system.</p><h4>How to protect yourself from non-stable APIs</h4><p>A simple rule: <strong>never ship experimental or canary APIs in your own production app</strong> unless a framework pins and manages them for you. You can enforce this with a tiny check in CI:</p><pre>// scripts/check-react-channel.js<br>import { readFileSync } from &quot;node:fs&quot;;<br><br>const pkg = JSON.parse(readFileSync(&quot;package.json&quot;, &quot;utf8&quot;));<br>const version = pkg.dependencies?.react ?? &quot;&quot;;<br>if (/experimental|canary|-rc|-beta/.test(version)) {<br>  console.error(`Unstable React channel detected: react@${version}`);<br>  process.exit(1); // fail the CI build<br>}<br>console.log(`React channel OK: ${version}`);</pre><p><strong>Explanation:</strong> the script reads your package.json, pulls out the React version string, and checks it against a regular expression that matches pre-release tags. If it finds one, it exits with code 1, which fails the CI pipeline. This turns a team agreement (&quot;stable only&quot;) into an automatic guardrail.</p><h3>Part 5 — The deprecation pipeline in practice</h3><p>APIs are not stable forever — sometimes an old design really does need to go. Stability is not “nothing ever changes”; it is <strong>“nothing changes by surprise.”</strong> React’s track record here is the best evidence of how the foundation is likely to behave, because the same maintainers now run the process.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Hdn4UbTv9nVgbzxttFV_3g.png" /></figure><p>The React 18 → 19 upgrade followed this pipeline closely. React <strong>18.3</strong> was released as a bridge: functionally the same as 18.2, but with new warnings for every API that React 19 would remove. Teams could upgrade to 18.3, fix the warnings, and only then move to 19. Let us look at three real removals.</p><h4>Example 1: ReactDOM.render → createRoot</h4><p><strong>Before (React 17 style — removed in React 19):</strong></p><pre>import ReactDOM from &quot;react-dom&quot;;<br>import App from &quot;./App&quot;;<br><br>ReactDOM.render(&lt;App /&gt;, document.getElementById(&quot;root&quot;));</pre><p><strong>After (React 18+ style):</strong></p><pre>import { createRoot } from &quot;react-dom/client&quot;;<br>import App from &quot;./App&quot;;<br><br>const root = createRoot(document.getElementById(&quot;root&quot;));<br>root.render(&lt;App /&gt;);</pre><p><strong>Explanation:</strong> the old render function did two jobs at once — it created React&#39;s internal tree <em>and</em> rendered into it. createRoot splits these: first you create a <strong>root</strong> object attached to a DOM node, then you call root.render() on it. This separation is what enables concurrent rendering features such as transitions and Suspense streaming. createRoot shipped in React 18 (2022), render was deprecated there, and it was only removed in React 19 (December 2024) — roughly two and a half years of overlap.</p><h4>Example 2: String refs → useRef</h4><p><strong>Before (removed in React 19):</strong></p><pre>class SearchBox extends React.Component {<br>  componentDidMount() {<br>    this.refs.input.focus(); // string ref lookup<br>  }<br>  render() {<br>    return &lt;input ref=&quot;input&quot; /&gt;;<br>  }<br>}</pre><p><strong>After:</strong></p><pre>import { useEffect, useRef } from &quot;react&quot;;<br><br>function SearchBox() {<br>  const inputRef = useRef(null);<br>  useEffect(() =&gt; {<br>    inputRef.current?.focus();<br>  }, []);<br>  return &lt;input ref={inputRef} /&gt;;<br>}</pre><p><strong>Explanation:</strong> a string ref (ref=&quot;input&quot;) asked React to find the element by name and attach it to this.refs — magic that was hard for React to optimize and impossible for tools to type-check. useRef(null) creates a plain object { current: null }. React fills current with the DOM node after mounting, and the Effect (which runs after mount) can safely call .focus(). The ?. guards against the node being missing.</p><h4>Example 3: propTypes and defaultProps on function components</h4><p><strong>Before:</strong></p><pre>import PropTypes from &quot;prop-types&quot;;<br><br>function Greeting({ name }) {<br>  return &lt;h1&gt;Hello, {name}&lt;/h1&gt;;<br>}<br>Greeting.propTypes = { name: PropTypes.string };<br>Greeting.defaultProps = { name: &quot;friend&quot; };</pre><p><strong>After:</strong></p><pre>type GreetingProps = { name?: string };<br><br>function Greeting({ name = &quot;friend&quot; }: GreetingProps) {<br>  return &lt;h1&gt;Hello, {name}&lt;/h1&gt;;<br>}</pre><p><strong>Explanation:</strong> propTypes checked prop types at runtime, in the browser. React 19 stopped running these checks entirely, in favor of compile-time checking with TypeScript (the GreetingProps type). defaultProps for function components was replaced by ordinary JavaScript default parameters (name = &quot;friend&quot;), which every developer already understands — less React-specific magic means less API surface to keep stable.</p><h4>The automated step: codemods</h4><p>A <strong>codemod</strong> is a script that rewrites your source code automatically. For React 19, the team published migration recipes you can run from the terminal:</p><pre># Run React 19 migration codemods across your project<br>npx codemod@latest react/19/migration-recipe</pre><p><strong>Explanation:</strong> the tool parses each file into an abstract syntax tree (a structured representation of your code), finds the old patterns — like ReactDOM.render or string refs — and rewrites them. You then review the diff in Git. Codemods turn a week of manual edits into a reviewable pull request.</p><p>This whole pipeline — warn, provide a codemod, document, <em>then</em> remove — is the strongest signal of what API stability looks like under the foundation. It is a process, and processes are written down, not tied to one company’s org chart.</p><h3>Part 6 — What changes, what stays the same</h3><p>The short answer: <strong>in the short term, almost nothing changes for your code; in the long term, the incentives behind every API decision change.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/777/1*RwL99ygFBxhDNhN2NXia1w.png" /></figure><h4>Why this is good for API stability</h4><ol><li><strong>No single kill switch.</strong> A company can lose interest in a project, reorganize a team or change strategy. A foundation with many funding members is far less likely to abandon React or push a sudden rewrite.</li><li><strong>More real-world testing.</strong> Under Meta, new APIs were battle-tested mostly on Meta’s apps. With Amazon, Microsoft, Expo, Vercel and others at the table, an API that breaks a common pattern is more likely to be caught before it reaches stable.</li><li><strong>Neutral ground for frameworks.</strong> Next.js, Expo, React Router and others can invest in React features knowing no single competitor controls the core. That encourages them to build on stable APIs rather than forks.</li><li><strong>Process over priorities.</strong> Foundations tend to document their RFC and breaking-change rules publicly. Written rules are easier for you to rely on and plan around.</li></ol><h4>The honest risks</h4><ol><li><strong>Slower decisions.</strong> Consensus takes time. A promising API might spend longer in canary. For stability, this is mostly a feature — but if you are waiting on a specific feature, expect patience.</li><li><strong>Governance is still provisional.</strong> As of this writing, the permanent technical structure has not been finalized. Until it is, the rules for breaking changes are still the inherited, informal ones. Watch the official React blog for the final model.</li><li><strong>Influence moves, it does not vanish.</strong> Companies that employ many maintainers will still shape priorities. The two-layer model limits the <em>board’s</em> power, but individual employers still matter. Healthy governance usually adds limits on how many seats one employer can hold.</li><li><strong>Meta’s testbed role shrinks.</strong> Meta ran React on some of the largest apps in the world. If Meta’s engineers contribute less over time, the project must replace that scale of real-world testing elsewhere.</li></ol><h4>A simple mental model</h4><p>Think of React as a public road. Before, one company owned the road, paved it well and mostly kept the rules consistent — but could, in theory, change them overnight. Now the road is owned by a public trust funded by many companies, with a separate traffic authority deciding the rules. Day to day, you will not notice a difference. Over ten years, you will be glad the rules can’t be changed by a single owner.</p><h3>Part 7 — Defensive coding: protect your app no matter who governs React</h3><p>Governance makes stability <em>likely</em>. Good engineering makes it <em>certain</em> for your app. These four patterns work under any owner.</p><h4>Pattern 1: An adapter layer for newer APIs</h4><p>Instead of importing newer React APIs directly in hundreds of files, import them from <strong>one file you own</strong>. If an API ever changes, you fix one file, not hundreds.</p><pre>// src/platform/react-compat.js — the ONLY file that touches newer APIs<br>import * as React from &quot;react&quot;;<br><br>// Fallback for React versions before 19.2, which lack useEffectEvent.<br>function useEffectEventFallback(callback) {<br>  const latest = React.useRef(callback);<br>  // Keep the ref pointing at the newest callback after every render.<br>  React.useLayoutEffect(() =&gt; {<br>    latest.current = callback;<br>  });<br>  // Return a stable function that always calls the newest callback.<br>  return React.useCallback((...args) =&gt; latest.current(...args), []);<br>}<br>export const useEffectEvent = React.useEffectEvent ?? useEffectEventFallback;</pre><pre>// src/features/chat/ChatRoom.jsx — feature code imports from YOUR layer<br>import { useEffect } from &quot;react&quot;;<br>import { useEffectEvent } from &quot;../../platform/react-compat&quot;;</pre><p><strong>Explanation, step by step:</strong></p><ul><li>import * as React loads everything React exports into one object, so we can <em>check</em> whether React.useEffectEvent exists.</li><li>The fallback stores the callback in a ref. A ref is a box whose contents can change without causing a re-render.</li><li>useLayoutEffect with no dependency array runs after every render, so latest.current always holds the newest callback (with the newest props).</li><li>useCallback(..., []) returns the <em>same</em> function identity forever. That stable identity is what lets you leave it out of Effect dependency arrays.</li><li>The last line picks the real API if it exists (?? means &quot;use the right side if the left is null or undefined&quot;). The choice happens once, when the module loads, so the rules of hooks are respected.</li></ul><p>The same idea works for any API you are nervous about: wrap it once, depend on your wrapper everywhere.</p><h4>Pattern 2: Controlled, grouped upgrades</h4><p>Upgrade react and react-dom together, and require a human to approve major versions. With Renovate, that is a few lines of config:</p><pre>{<br>  &quot;extends&quot;: [&quot;config:recommended&quot;],<br>  &quot;packageRules&quot;: [<br>    {<br>      &quot;matchPackageNames&quot;: [&quot;react&quot;, &quot;react-dom&quot;, &quot;@types/react&quot;, &quot;@types/react-dom&quot;],<br>      &quot;groupName&quot;: &quot;react&quot;<br>    },<br>    {<br>      &quot;matchPackageNames&quot;: [&quot;react&quot;, &quot;react-dom&quot;],<br>      &quot;matchUpdateTypes&quot;: [&quot;major&quot;],<br>      &quot;dependencyDashboardApproval&quot;: true<br>    }<br>  ]<br>}</pre><p><strong>Explanation:</strong> the first rule bundles React and its type definitions into a single pull request, so they never drift out of sync (mismatched react and react-dom versions are a classic source of strange bugs). The second rule says that a <strong>major</strong> upgrade does not open automatically — someone must approve it from the dependency dashboard, ideally after reading the upgrade guide. Patches and minors still flow in automatically, which keeps you current on security fixes.</p><h4>Pattern 3: Treat development warnings as errors in tests</h4><p>Remember Figure 4: deprecations arrive first as <strong>console warnings in development</strong>. If your test suite fails on those warnings, you learn about a deprecation the day it lands, not the day it is removed.</p><pre>// vitest.setup.js (works the same way with Jest&#39;s setupFiles)<br>const originalError = console.error;<br>const originalWarn = console.warn;<br><br>function failOnReactWarning(original) {<br>  return (...args) =&gt; {<br>    const message = String(args[0] ?? &quot;&quot;);<br>    if (/deprecated|will be removed|is no longer supported/i.test(message)) {<br>      throw new Error(`React deprecation warning: ${message}`);<br>    }<br>    original(...args);<br>  };<br>}<br>console.error = failOnReactWarning(originalError);<br>console.warn = failOnReactWarning(originalWarn);</pre><p><strong>Explanation:</strong> this setup file runs before every test. It replaces console.error and console.warn with wrappers that inspect each message. If the message looks like a deprecation notice, the wrapper throws, which fails the test that triggered it. Other messages pass through to the original function unchanged. Adjust the regular expression to match the exact wording you see in your project.</p><h4>Pattern 4: A small “contract test” suite</h4><p>Write a few tests that check the React behaviors your app relies on most. When you upgrade, these tests tell you immediately whether the contract still holds.</p><pre>// Counter.contract.test.jsx<br>import { render, screen } from &quot;@testing-library/react&quot;;<br>import userEvent from &quot;@testing-library/user-event&quot;;<br>import { useState } from &quot;react&quot;;<br><br>function Counter() {<br>  const [count, setCount] = useState(0);<br>  return &lt;button onClick={() =&gt; setCount((c) =&gt; c + 1)}&gt;Count: {count}&lt;/button&gt;;<br>}<br>test(&quot;useState contract: setter triggers a re-render with the new value&quot;, async () =&gt; {<br>  render(&lt;Counter /&gt;);<br>  const button = screen.getByRole(&quot;button&quot;);<br>  await userEvent.click(button);<br>  await userEvent.click(button);<br>  expect(button).toHaveTextContent(&quot;Count: 2&quot;);<br>});</pre><p><strong>Explanation:</strong> render mounts the component into a simulated DOM. screen.getByRole(&quot;button&quot;) finds the button the way a user or screen reader would. Two simulated clicks should call the setter twice, and the functional update (c) =&gt; c + 1 guarantees each click builds on the latest value. The assertion checks the visible result. If a future React version ever changed how state updates behave, this test would fail on the upgrade branch — long before your users noticed.</p><h3>Part 8 — A checklist for your team</h3><ul><li>Confirm every app is on a <strong>stable</strong> React version (no experimental or canary tags in production).</li><li>Make sure react and react-dom are always the same version.</li><li>Pick a version-range policy: ^ for most apps, ~ or exact pins for regulated or high-risk apps.</li><li>Route newer or riskier React APIs through a single adapter file.</li><li>Fail tests on React deprecation warnings.</li><li>Add a handful of contract tests for the React behaviors you depend on most.</li><li>Require human approval for major React upgrades, and read the official upgrade guide first.</li><li>Follow the official React blog for the final technical governance model and any change to the deprecation policy.</li></ul><h3>Conclusion</h3><p>React leaving Meta is not a rewrite, a fork or a reason to panic. On day one, nothing in your code changed. What changed is the structure behind every future decision: React is now owned by a neutral foundation, funded by eight major members, with technical decisions deliberately kept away from the board.</p><p>For API stability, that is good news. Stability has always rested on a process — release channels, semver, warnings, codemods and upgrade guides — and that process is now owned by a community instead of a single company. The remaining open question is the permanent shape of technical governance, so keep an eye on it.</p><p>In the meantime, the best protection is the same as always: stay on stable releases, upgrade deliberately, and let your tests tell you the moment a contract changes.</p><h3>Sources</h3><ul><li><a href="https://react.dev/blog/2026/02/24/the-react-foundation">The React Foundation: A New Home for React Hosted by the Linux Foundation</a> — React blog, Feb 24, 2026</li><li><a href="https://react.dev/blog/2025/10/07/introducing-the-react-foundation">Introducing the React Foundation</a> — React blog, Oct 7, 2025</li><li><a href="https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-react-foundation">Linux Foundation Announces the Formation of the React Foundation</a> — Linux Foundation press release</li><li><a href="https://www.i-programmer.info/news/87-web-development/18714-react-foundation-officially-launched.html">React Foundation Officially Launched</a> — I Programmer, Mar 9, 2026</li><li><a href="https://theregister.com/2025/10/09/meta_react_foundation">Meta will move React to Linux Foundation</a> — The Register, Oct 9, 2025</li><li><a href="https://react.dev/blog/2025/10/01/react-19-2">React 19.2 release post</a> — React blog</li><li><a href="https://github.com/facebook/react/releases">React releases and changelog</a> — GitHub</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6f2efe325231" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Canary Tokens for the AI Era: Detecting Rogue Agents in Your Cloud]]></title>
            <link>https://medium.com/@vasanthank29/canary-tokens-for-the-ai-era-detecting-rogue-agents-in-your-cloud-7677ac95b946?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/7677ac95b946</guid>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[cloud-security]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[typescript]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Wed, 21 Oct 2026 13:01:03 GMT</pubDate>
            <atom:updated>2026-10-21T13:01:03.370Z</atom:updated>
            <content:encoded><![CDATA[<p><em>AI agents can compromise a cloud account in minutes. Here is how a decades-old deception trick — the canary token — becomes your fastest, most reliable alarm against them.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*S3nx3hJkXoZKVgnlTFADUA.png" /></figure><h4>The minutes that matter</h4><p>Autonomous AI agents are changing the speed of cloud attacks. In controlled tests run by the security firm Tracebit, <a href="https://newsletter.tracebit.com/p/context-bombs-stopping-ai-attackers">frontier AI agents reached a critical action in about 14 minutes on average, leaving defenders a window of roughly 8 minutes</a>. Most security teams cannot even read an alert in that time, let alone investigate it.</p><p>Traditional detection struggles here. Anomaly detection needs a baseline and produces noise. Signature rules miss new techniques. Log reviews happen hours later. What defenders need is an alarm that is <strong>instant, cheap, and almost never wrong</strong>.</p><p>That alarm already exists. It is called a <strong>canary token</strong>: a fake secret that no legitimate person or system should ever touch. The moment anything reads it, you know something is wrong. And as you will see, AI agents are unusually good at stepping on these tripwires.</p><h4>TL;DR</h4><ul><li><strong>What:</strong> a canary token is a decoy credential, file or resource. Any access to it is a high-confidence signal of intrusion.</li><li><strong>Why now:</strong> AI attack agents enumerate everything, move fast, and do not get suspicious. That makes them trip canaries far more reliably than many human attackers.</li><li><strong>Evidence:</strong> in published research, <a href="https://www.csoonline.com/article/4198524/context-bombing-heralds-a-new-ai-era-of-deceptive-defense.html">canaries alerted defenders in 95.9% of 162 successful AI-agent attack runs across 10 models</a>.</li><li><strong>What you will build:</strong> realistic canary secrets in AWS Secrets Manager (TypeScript), an EventBridge rule that watches CloudTrail, a responder Lambda that alerts Slack and contains the identity, and a local simulator to test it all without an AWS account.</li><li><strong>Remember:</strong> canaries detect; they do not prevent. They belong on top of least privilege, not instead of it.</li></ul><h4>Who this is for</h4><p>Beginners are welcome. Every concept is explained before the code uses it, and every code block is followed by a walkthrough of <em>why</em> it is written that way. If you already know canaries well, skip to the hands-on build.</p><h3>Foundations: what is a canary token?</h3><p>A canary token is a piece of bait that has <strong>no legitimate use</strong>. Because nobody should ever touch it, any touch is evidence of an intruder.</p><p>The name comes from coal mining. Miners carried canaries underground because the birds reacted to toxic gas before humans did. The bird was an early warning system. A canary token plays the same role in your infrastructure: it reacts first, so you can act before real damage is done.</p><h4>A short history</h4><p>The idea is older than the cloud. Security researchers in the early 2000s called these objects <strong>honeytokens</strong>, a cousin of the <strong>honeypot</strong> (a fake server built to attract attackers). The difference is size. A honeypot is a whole machine you must maintain. A honeytoken is a single fake item — a password, an API key, a database row, a document — that you sprinkle inside your <em>real</em> environment.</p><p>Over time the pattern spread everywhere:</p><ul><li><strong>Fake database rows</strong> that alert when someone exports the customer table.</li><li><strong>Fake AWS access keys</strong> left in code repositories, which alert when an attacker tries to use them.</li><li><strong>Fake documents</strong> that phone home when opened.</li><li><strong>Fake secrets</strong> in a secrets manager, which alert when read. This is the type we build in this article.</li></ul><h4>Why canaries have almost no false positives</h4><p>Most detection tools try to answer a hard question: <em>is this behaviour unusual?</em> That requires a baseline of “normal”, and normal changes every day. The result is alert fatigue — teams drown in warnings and start ignoring them.</p><p>A canary answers a much easier question: <em>did anyone touch the thing nobody should touch?</em> That is a yes/no fact, not a guess. If you design the canary well, the honest answer to “who should read this?” is “nobody”, so every alert deserves attention.</p><p>Think of it like a motion sensor in a locked room that no employee has a key to. You do not need machine learning to know that motion in that room is a problem.</p><h4>The three properties of a good canary</h4><ol><li><strong>Believable.</strong> It must look like something an attacker wants: a production database password, an admin API key, a payment-provider token. A canary named fake-secret-123 catches nobody.</li><li><strong>Unused.</strong> No real application, script or person reads it. This is what keeps false positives near zero.</li><li><strong>Observable.</strong> Every access is logged and routed to an alert within seconds. In AWS, CloudTrail records each GetSecretValue call, which makes Secrets Manager a natural home for canaries.</li></ol><h4>Key terms used in this article</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/552/1*2szBd0Qfq-9LVInPyd3lvQ.png" /></figure><h3>Why AI agents trip canaries so reliably</h3><p>AI attack agents are fast, but their habits make them easy to catch with decoys. Three traits matter most.</p><h4>1. They enumerate everything</h4><p>An agent works in a loop: the model looks at what it knows, picks a tool to call, reads the result, and repeats. When an agent lands in a cloud account with stolen credentials, the cheapest next step is almost always to <em>list</em> things — buckets, roles, functions and secrets — and then <em>read</em> the interesting ones.</p><p>An experienced human attacker might pause and ask, “Is prod/payments/stripe-live-key too convenient? Could it be a trap?&quot; An agent chasing a goal like &quot;find credentials that grant more access&quot; rarely asks that question. A secret with a juicy name looks like progress, so it gets read.</p><h4>2. They are fast, so they hit the trap early</h4><p>Speed cuts both ways. The same speed that shrinks your response window also means the agent reaches your decoys within its first few minutes. A canary placed where enumeration naturally leads becomes one of the first things an agent touches — often long before it finds anything real.</p><h4>3. They do not get tired or bored</h4><p>Humans skip things. Agents are systematic. If ten secrets are listed, an agent may try to read all ten. Each extra read is another chance to hit a canary. Mixing several canaries among real resources raises the odds even more.</p><h4>What the research shows</h4><p>This is not just theory. Tracebit pointed frontier models at a realistic AWS environment and measured how often canaries fired. According to coverage in CSO Online, <a href="https://www.csoonline.com/article/4198524/context-bombing-heralds-a-new-ai-era-of-deceptive-defense.html">canaries alerted defenders in 95.9% of 162 successful attack runs performed by agents using 10 different AI models</a>. In other words, when an agent succeeded at <em>something</em>, it almost always tripped a wire on the way.</p><p>That result is the core argument of this article: <strong>you do not need to out-think an AI attacker to detect it. You need to put bait where its loop will naturally look.</strong></p><h4>The timeline problem</h4><p>Detection only matters if it happens before the damage. The diagram below compares a typical agent attack timeline with a manual response and an automated, canary-driven response. With a manual process, the alert may arrive in time but the human reaction does not. With automation, the canary read triggers containment in seconds — well inside the window.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*p7XyxDfyLr0bFWpu0WHzLA.png" /></figure><h3>The architecture: from bait to containment in seconds</h3><p>The whole system is five AWS building blocks connected in a straight line, plus one optional branch for containment. Here is the flow before we write any code.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*m3-qm9f-UBhMD1iPiw49fw.png" /></figure><ol><li><strong>The canary secret</strong> lives in AWS Secrets Manager next to your real secrets. It has a believable name and a believable (but fake) value. Nothing in your system reads it.</li><li><strong>CloudTrail</strong> records the read. Every call to GetSecretValue becomes an event that includes <em>who</em> called (the principal ARN), <em>from where</em> (source IP, user agent) and <em>what</em> they asked for (the secret ID).</li><li><strong>EventBridge</strong> filters the stream. A rule matches only events where the requested secret is one of your canaries, so the downstream function runs only when it matters.</li><li><strong>The responder Lambda</strong> enriches and acts. It builds a readable alert, posts it to Slack (or SNS, PagerDuty, email), and — if you enable it — contains the identity.</li><li><strong>Containment</strong> attaches an explicit-deny policy to the IAM role or user that read the canary. In IAM, an explicit deny always beats an allow, so this instantly stops that identity from doing anything else.</li></ol><h4>Why this design?</h4><p><strong>Why Secrets Manager and not a fake access key?</strong> Reading a secret is exactly what an agent does when it looks for privilege escalation. The read itself is the signal; the attacker does not even have to <em>use</em> the fake value.</p><p><strong>Why EventBridge instead of polling logs?</strong> Polling means delay and cost. EventBridge pushes matching events to Lambda as they arrive, typically within seconds to a few minutes of the API call, with no servers to run.</p><p><strong>Why automate containment?</strong> Because of the timeline problem. If the agent’s whole attack fits inside 8 minutes, a human approval step may arrive too late. We will make containment a configurable switch so you can start in alert-only mode and turn it on once you trust it.</p><p><strong>Why an explicit deny instead of deleting the role?</strong> Deleting things destroys evidence and can break production. A deny policy is reversible: remove it after the investigation and everything works again.</p><h4>Project layout</h4><p>All the code in the next sections fits into this small TypeScript project:</p><pre>canary-agent-trap/<br>├── src/<br>│   ├── canaries.ts          # Step 1: create canary secrets<br>│   ├── responder.ts         # Step 3: Lambda that alerts + contains<br>│   └── simulate.ts          # Step 4: local rogue-agent simulator<br>├── infra/<br>│   └── canary-stack.ts      # Step 2: CDK stack (EventBridge rule + Lambda)<br>├── canaries.json            # the list of canary names (your source of truth)<br>├── package.json<br>└── tsconfig.json</pre><p>Install the dependencies once:</p><pre>npm init -y<br>npm install @aws-sdk/client-secrets-manager @aws-sdk/client-iam<br>npm install -D typescript tsx @types/node @types/aws-lambda aws-cdk-lib constructs</pre><h3>Step 1: Create realistic canary secrets</h3><p>The first job is to plant bait that looks real. We keep the list of canaries in a JSON file so every other component — the detection rule, the responder, the simulator — reads from one source of truth.</p><h4>canaries.json</h4><pre>{<br>  &quot;region&quot;: &quot;us-east-1&quot;,<br>  &quot;canaries&quot;: [<br>    {<br>      &quot;name&quot;: &quot;prod/payments/stripe-live-secret&quot;,<br>      &quot;description&quot;: &quot;Live Stripe secret key for checkout-service&quot;,<br>      &quot;kind&quot;: &quot;api-key&quot;<br>    },<br>    {<br>      &quot;name&quot;: &quot;prod/rds/admin-credentials&quot;,<br>      &quot;description&quot;: &quot;Master credentials for orders-db cluster&quot;,<br>      &quot;kind&quot;: &quot;db-credentials&quot;<br>    },<br>    {<br>      &quot;name&quot;: &quot;ci/github/deploy-token&quot;,<br>      &quot;description&quot;: &quot;Deploy token used by release pipeline&quot;,<br>      &quot;kind&quot;: &quot;token&quot;<br>    }<br>  ]<br>}</pre><p><strong>Theory — why these names?</strong> Attackers (human or AI) look for three things: <em>production</em>, <em>privilege</em> and <em>money</em>. Names containing prod, admin, live and payments signal all three. Copy the naming conventions your real secrets already use. If your real secrets look like orders-svc/prod/db, a canary called prod/rds/admin-credentials will stand out as fake to a careful reader.</p><h4>src/canaries.ts</h4><pre>import { readFileSync } from &quot;node:fs&quot;;<br>import { randomBytes } from &quot;node:crypto&quot;;<br>import {<br>  SecretsManagerClient,<br>  CreateSecretCommand,<br>  ResourceExistsException,<br>} from &quot;@aws-sdk/client-secrets-manager&quot;;<br><br>type CanaryKind = &quot;api-key&quot; | &quot;db-credentials&quot; | &quot;token&quot;;<br>interface CanaryDef {<br>  name: string;<br>  description: string;<br>  kind: CanaryKind;<br>}<br>const config = JSON.parse(readFileSync(&quot;canaries.json&quot;, &quot;utf8&quot;)) as {<br>  region: string;<br>  canaries: CanaryDef[];<br>};<br>const client = new SecretsManagerClient({ region: config.region });<br>// Random characters so every value looks unique and plausible.<br>function randomString(length: number): string {<br>  const alphabet = &quot;ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789&quot;;<br>  const bytes = randomBytes(length);<br>  return Array.from(bytes, (b) =&gt; alphabet[b % alphabet.length]).join(&quot;&quot;);<br>}<br>// Build a fake value that matches the shape of a real one.<br>// None of these values work anywhere - they are pure decoys.<br>function fakeValue(kind: CanaryKind): string {<br>  switch (kind) {<br>    case &quot;api-key&quot;:<br>      return JSON.stringify({ apiKey: `sk_live_${randomString(32)}` });<br>    case &quot;db-credentials&quot;:<br>      return JSON.stringify({<br>        engine: &quot;postgres&quot;,<br>        host: &quot;orders-db.cluster-internal.example&quot;,<br>        port: 5432,<br>        username: &quot;db_admin&quot;,<br>        password: randomString(24),<br>      });<br>    case &quot;token&quot;:<br>      return JSON.stringify({ token: `ghp_${randomString(36)}` });<br>  }<br>}<br>async function createCanary(def: CanaryDef): Promise&lt;void&gt; {<br>  try {<br>    await client.send(<br>      new CreateSecretCommand({<br>        Name: def.name,<br>        Description: def.description,<br>        SecretString: fakeValue(def.kind),<br>        // Tags look like a real team&#39;s tags. Never tag it &quot;canary&quot;:<br>        // anyone who can list secrets can also read tags.<br>        Tags: [<br>          { Key: &quot;team&quot;, Value: &quot;platform&quot; },<br>          { Key: &quot;env&quot;, Value: &quot;prod&quot; },<br>        ],<br>      }),<br>    );<br>    console.log(`created  ${def.name}`);<br>  } catch (err) {<br>    if (err instanceof ResourceExistsException) {<br>      console.log(`exists   ${def.name} (skipped)`);<br>      return;<br>    }<br>    throw err;<br>  }<br>}<br>async function main(): Promise&lt;void&gt; {<br>  for (const def of config.canaries) {<br>    await createCanary(def);<br>  }<br>}<br>main().catch((err) =&gt; {<br>  console.error(err);<br>  process.exit(1);<br>});</pre><p>Run it with npx tsx src/canaries.ts.</p><h4>Walkthrough: what each part does and why</h4><p><strong>Reading </strong><strong>canaries.json.</strong> We load the canary list from a file instead of hard-coding it. Later, the CDK stack reads the same file to build the detection rule. One list means you can never add a canary and forget to watch it.</p><p><strong>randomString uses </strong><strong>crypto.randomBytes.</strong> Values must be unique per canary and unpredictable. If every canary shared the same value, an attacker who saw two of them would know they are fake. Math.random() would also work for decoys, but using the crypto module is a good habit for anything credential-shaped.</p><p><strong>fakeValue mimics real formats.</strong> A Stripe live key starts with sk_live_; a GitHub personal token starts with ghp_; database secrets are JSON with host, port, username and password. Matching these shapes matters because an agent may parse the value and plan its next step from it. A realistic value keeps the decoy believable. The host uses the reserved .example domain so it can never resolve to a real machine.</p><p><strong>Tags are camouflage.</strong> It is tempting to tag canaries with purpose=canary so your team can find them. Do not do this. ListSecrets returns tags, so the label would warn the attacker. Keep the canary list in canaries.json (in a private repo) instead.</p><p><strong>Idempotency with </strong><strong>ResourceExistsException.</strong> Running the script twice should not crash. Catching &quot;already exists&quot; makes the script safe to re-run in CI.</p><p><strong>Why reading is enough.</strong> Notice we never need the attacker to <em>use</em> these values. The GetSecretValue call is logged by CloudTrail the moment it happens, which is what Step 2 watches for.</p><h3>Step 2: Detect the read with EventBridge and CDK</h3><p>Now we need to <em>see</em> every read of a canary. We use AWS CDK (infrastructure as code in TypeScript) to create an EventBridge rule and wire it to the responder Lambda from Step 3.</p><h4>A gotcha beginners hit: read-only events</h4><p>GetSecretValue is a <strong>read-only</strong> API call. By default, EventBridge rules ignore read-only management events from CloudTrail, because they are extremely high-volume. To receive them, the rule must use a special state, <a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-service-event-cloudtrail-management.html">ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS, which today can only be set through the CLI or CloudFormation</a>. AWS added this capability in <a href="https://aws.amazon.com/about-aws/whats-new/2023/11/amazon-eventbridge-read-only-api-cloudtrail">late 2023</a>.</p><p>If you skip this, your rule deploys fine, looks correct in the console — and never fires. Also confirm that CloudTrail is logging management events (read and write) in the account and region where your canaries live.</p><h4>infra/canary-stack.ts</h4><pre>import { readFileSync } from &quot;node:fs&quot;;<br>import * as cdk from &quot;aws-cdk-lib&quot;;<br>import { Construct } from &quot;constructs&quot;;<br>import * as events from &quot;aws-cdk-lib/aws-events&quot;;<br>import * as targets from &quot;aws-cdk-lib/aws-events-targets&quot;;<br>import * as lambda from &quot;aws-cdk-lib/aws-lambda&quot;;<br>import * as nodejs from &quot;aws-cdk-lib/aws-lambda-nodejs&quot;;<br>import * as iam from &quot;aws-cdk-lib/aws-iam&quot;;<br><br>const { canaries } = JSON.parse(readFileSync(&quot;canaries.json&quot;, &quot;utf8&quot;)) as {<br>  canaries: { name: string }[];<br>};<br>export class CanaryStack extends cdk.Stack {<br>  constructor(scope: Construct, id: string, props?: cdk.StackProps) {<br>    super(scope, id, props);<br>    // 1. The responder Lambda (code in src/responder.ts)<br>    const responder = new nodejs.NodejsFunction(this, &quot;CanaryResponder&quot;, {<br>      entry: &quot;src/responder.ts&quot;,<br>      runtime: lambda.Runtime.NODEJS_20_X,<br>      timeout: cdk.Duration.seconds(30),<br>      environment: {<br>        SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK_URL ?? &quot;&quot;,<br>        CONTAIN: process.env.CONTAIN ?? &quot;false&quot;, // start in alert-only mode<br>        ALLOWLIST_PRINCIPALS: process.env.ALLOWLIST_PRINCIPALS ?? &quot;&quot;,<br>      },<br>    });<br>    // The responder may attach a deny policy to roles and users - nothing more.<br>    responder.addToRolePolicy(<br>      new iam.PolicyStatement({<br>        actions: [&quot;iam:PutRolePolicy&quot;, &quot;iam:PutUserPolicy&quot;],<br>        resources: [&quot;*&quot;],<br>      }),<br>    );<br>    // 2. Match a canary whether it was requested by name or by full ARN.<br>    const secretIdMatchers = canaries.flatMap((c) =&gt; [<br>      c.name,<br>      { wildcard: `arn:aws:secretsmanager:*:*:secret:${c.name}-*` },<br>    ]);<br>    const rule = new events.Rule(this, &quot;CanaryReadRule&quot;, {<br>      description: &quot;Fires when any canary secret is read&quot;,<br>      eventPattern: {<br>        source: [&quot;aws.secretsmanager&quot;],<br>        detailType: [&quot;AWS API Call via CloudTrail&quot;],<br>        detail: {<br>          eventSource: [&quot;secretsmanager.amazonaws.com&quot;],<br>          eventName: [&quot;GetSecretValue&quot;],<br>          requestParameters: { secretId: secretIdMatchers },<br>        },<br>      },<br>      targets: [new targets.LambdaFunction(responder, { retryAttempts: 2 })],<br>    });<br>    // 3. Escape hatch: opt in to read-only management events.<br>    (rule.node.defaultChild as events.CfnRule).state =<br>      &quot;ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS&quot;;<br>  }<br>}<br>const app = new cdk.App();<br>new CanaryStack(app, &quot;CanaryAgentTrap&quot;);</pre><p>Deploy with npx cdk deploy --app &quot;npx tsx infra/canary-stack.ts&quot;.</p><h4>Walkthrough: how the rule thinks</h4><p><strong>An event pattern is a filter, not a query.</strong> EventBridge compares every incoming event to the pattern. Each key must match; inside an array, <em>any</em> value can match. So the pattern reads: “source is Secrets Manager AND it is a CloudTrail API call AND the action is GetSecretValue AND the secret ID is one of my canaries.&quot; Everything else is dropped for free, before your Lambda runs.</p><p><strong>Why two matchers per canary?</strong> A caller can ask for a secret by its short name (prod/rds/admin-credentials) or by its full ARN. Secrets Manager adds a random six-character suffix to ARNs (for example ...:secret:prod/rds/admin-credentials-Ab12Cd). The wildcard matcher catches every ARN form, whatever the suffix.</p><p><strong>Why read the same </strong><strong>canaries.json?</strong> Single source of truth. Add a canary in Step 1, redeploy, and the rule picks it up automatically.</p><p><strong>Why </strong><strong>retryAttempts: 2?</strong> If the Lambda fails (Slack is down, a throttle), EventBridge retries. An alarm that silently fails once is worse than useless.</p><p><strong>Why the escape hatch?</strong> CDK’s high-level Rule construct exposes only enabled or disabled. We reach into the underlying CloudFormation resource (CfnRule) and set the special state ourselves. This is a common CDK pattern whenever a feature is newer than the library&#39;s abstractions.</p><p><strong>Least privilege for the defender too.</strong> The responder can only write inline policies to users and roles. It cannot delete anything, create users, or read your secrets. If the responder itself were ever compromised, the blast radius is small.</p><h3>Step 3: The responder — alert, then contain</h3><p>When the rule fires, EventBridge hands our Lambda the full CloudTrail record. The responder does four things in order: <strong>parse</strong> who did it, <strong>check</strong> the allowlist, <strong>alert</strong> humans, and optionally <strong>contain</strong> the identity.</p><h4>src/responder.ts</h4><pre>import { IAMClient, PutRolePolicyCommand, PutUserPolicyCommand } from &quot;@aws-sdk/client-iam&quot;;<br>import type { EventBridgeEvent } from &quot;aws-lambda&quot;;<br><br>const iam = new IAMClient({});<br>// ---- Types: only the CloudTrail fields we use ----<br>export interface CloudTrailDetail {<br>  eventTime: string;<br>  eventName: string;<br>  sourceIPAddress?: string;<br>  userAgent?: string;<br>  requestParameters?: { secretId?: string };<br>  userIdentity: {<br>    type: string; // &quot;IAMUser&quot; | &quot;AssumedRole&quot; | &quot;Root&quot; | ...<br>    arn?: string;<br>    userName?: string;<br>    sessionContext?: { sessionIssuer?: { type?: string; userName?: string; arn?: string } };<br>  };<br>}<br>export interface Finding {<br>  secretId: string;<br>  principalArn: string;<br>  identityType: string;<br>  roleName?: string;<br>  userName?: string;<br>  sourceIp: string;<br>  userAgent: string;<br>  time: string;<br>  looksLikeAiAgent: boolean;<br>}<br>// ---- 1. Parse ----<br>export function toFinding(d: CloudTrailDetail): Finding {<br>  const issuer = d.userIdentity.sessionContext?.sessionIssuer;<br>  const ua = d.userAgent ?? &quot;unknown&quot;;<br>  return {<br>    secretId: d.requestParameters?.secretId ?? &quot;unknown&quot;,<br>    principalArn: issuer?.arn ?? d.userIdentity.arn ?? &quot;unknown&quot;,<br>    identityType: d.userIdentity.type,<br>    roleName: issuer?.type === &quot;Role&quot; ? issuer.userName : undefined,<br>    userName: d.userIdentity.type === &quot;IAMUser&quot; ? d.userIdentity.userName : undefined,<br>    sourceIp: d.sourceIPAddress ?? &quot;unknown&quot;,<br>    userAgent: ua,<br>    time: d.eventTime,<br>    // A weak hint only: scripted SDK/CLI calls, not the console.<br>    looksLikeAiAgent: /boto3|aws-cli|aws-sdk|python-requests|langchain|mcp/i.test(ua),<br>  };<br>}<br>// ---- 2. Allowlist ----<br>export function isAllowlisted(f: Finding, allowlist: string[]): boolean {<br>  return allowlist.some((a) =&gt; a &amp;&amp; f.principalArn.startsWith(a));<br>}<br>// ---- 3. Alert ----<br>export function formatAlert(f: Finding, contained: string): string {<br>  return [<br>    `:rotating_light: *Canary secret read* - likely intrusion`,<br>    `*Secret:* ${f.secretId}`,<br>    `*Principal:* ${f.principalArn} (${f.identityType})`,<br>    `*Source IP:* ${f.sourceIp}`,<br>    `*User agent:* ${f.userAgent}${f.looksLikeAiAgent ? &quot;  _(scripted / possible agent)_&quot; : &quot;&quot;}`,<br>    `*Time:* ${f.time}`,<br>    `*Containment:* ${contained}`,<br>  ].join(&quot;\n&quot;);<br>}<br>async function sendSlack(text: string): Promise&lt;void&gt; {<br>  const url = process.env.SLACK_WEBHOOK_URL;<br>  if (!url) {<br>    console.warn(&quot;SLACK_WEBHOOK_URL not set; alert only logged&quot;);<br>    return;<br>  }<br>  const res = await fetch(url, {<br>    method: &quot;POST&quot;,<br>    headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },<br>    body: JSON.stringify({ text }),<br>  });<br>  if (!res.ok) throw new Error(`Slack returned ${res.status}`);<br>}<br>// ---- 4. Contain ----<br>export function denyPolicy(now: Date, revokeSessionsOnly: boolean): string {<br>  const statement: Record&lt;string, unknown&gt; = {<br>    Sid: &quot;CanaryContainment&quot;,<br>    Effect: &quot;Deny&quot;,<br>    Action: &quot;*&quot;,<br>    Resource: &quot;*&quot;,<br>  };<br>  if (revokeSessionsOnly) {<br>    // Deny only credentials issued before &quot;now&quot; - the same idea as<br>    // the console&#39;s &quot;Revoke active sessions&quot; button.<br>    statement.Condition = { DateLessThan: { &quot;aws:TokenIssueTime&quot;: now.toISOString() } };<br>  }<br>  return JSON.stringify({ Version: &quot;2012-10-17&quot;, Statement: [statement] });<br>}<br>async function contain(f: Finding): Promise&lt;string&gt; {<br>  const now = new Date();<br>  if (f.roleName) {<br>    await iam.send(new PutRolePolicyCommand({<br>      RoleName: f.roleName,<br>      PolicyName: &quot;canary-containment&quot;,<br>      PolicyDocument: denyPolicy(now, true),<br>    }));<br>    return `revoked active sessions of role ${f.roleName}`;<br>  }<br>  if (f.userName) {<br>    await iam.send(new PutUserPolicyCommand({<br>      UserName: f.userName,<br>      PolicyName: &quot;canary-containment&quot;,<br>      PolicyDocument: denyPolicy(now, false),<br>    }));<br>    return `denied all actions for user ${f.userName}`;<br>  }<br>  return `not supported for identity type ${f.identityType} - respond manually`;<br>}<br>// ---- Handler ----<br>export async function handler(<br>  event: EventBridgeEvent&lt;&quot;AWS API Call via CloudTrail&quot;, CloudTrailDetail&gt;,<br>): Promise&lt;void&gt; {<br>  const finding = toFinding(event.detail);<br>  const allowlist = (process.env.ALLOWLIST_PRINCIPALS ?? &quot;&quot;).split(&quot;,&quot;);<br>  // Structured log line: easy to search in CloudWatch Logs Insights.<br>  console.log(JSON.stringify({ msg: &quot;canary_read&quot;, ...finding }));<br>  if (isAllowlisted(finding, allowlist)) {<br>    console.log(JSON.stringify({ msg: &quot;allowlisted_principal&quot;, principal: finding.principalArn }));<br>    return;<br>  }<br>  let contained = &quot;disabled (alert-only mode)&quot;;<br>  if (process.env.CONTAIN === &quot;true&quot;) {<br>    try {<br>      contained = await contain(finding);<br>    } catch (err) {<br>      contained = `FAILED: ${(err as Error).message}`;<br>    }<br>  }<br>  await sendSlack(formatAlert(finding, contained));<br>}</pre><h4>Walkthrough: the decisions behind the code</h4><p><strong>Pure functions first, side effects last.</strong> toFinding, isAllowlisted, formatAlert and denyPolicy take data and return data. They never call AWS or Slack. That separation is what lets Step 4 test the whole logic on your laptop. Only contain, sendSlack and handler touch the outside world.</p><p><strong>Finding the real identity.</strong> When an agent uses a role, CloudTrail’s userIdentity.arn is a <em>session</em> ARN such as arn:aws:sts::123456789012:assumed-role/ci-runner/session-xyz. You cannot attach a policy to a session. The sessionIssuer block tells us the underlying role (ci-runner), which is what we contain. For IAM users, the user name is right in userIdentity.</p><p><strong>Two containment strategies.</strong> For an <strong>IAM user</strong> (long-lived access keys, a classic leak), we deny everything: those keys should never work again. For a <strong>role</strong>, a blanket deny would also break every legitimate service using that role — possibly your production app. Instead we use the condition aws:TokenIssueTime with DateLessThan: it denies only credentials issued <em>before</em> the incident. The attacker&#39;s stolen session dies; fresh sessions for your real workloads keep working. AWS uses the same technique behind its &quot;Revoke active sessions&quot; console button.</p><p><strong>Why </strong><strong>Action: &quot;*&quot; with </strong><strong>Effect: &quot;Deny&quot;?</strong> IAM evaluates explicit denies before any allow. No matter what permissions the attacker had, this single statement overrides them all.</p><p><strong>Alert even when containment fails.</strong> Notice the try/catch around contain. If containment fails (for example, the role is protected by a permissions boundary), we still send the alert with FAILED in it. A human must know.</p><p><strong>The “looks like an AI agent” hint is deliberately weak.</strong> User-agent strings are easy to fake. We use it only to add context to the alert, never to decide anything. The canary read itself is the real signal — whoever made it, human or agent, should not have.</p><p><strong>Alert-only by default.</strong> CONTAIN=false is the default. Run in alert-only mode for a couple of weeks, review every alert, tune the allowlist, then turn containment on. Automation you do not trust gets switched off; automation you have tested gets left on.</p><h3>Step 4: Test it locally with a simulated rogue agent</h3><p>You should never wait for a real attacker to find out whether your alarm works. This step builds a tiny simulator that runs on your laptop, with no AWS account and no LLM. It mixes real-looking secrets with your canaries, lets a scripted “agent” choose what to read, and runs the responder’s pure functions on the result.</p><h3>src/simulate.ts</h3><pre>import { readFileSync } from &quot;node:fs&quot;;<br>import { toFinding, formatAlert, denyPolicy, type CloudTrailDetail } from &quot;./responder&quot;;<br><br>// ---- 1. A fake account: real secrets mixed with canaries ----<br>const { canaries } = JSON.parse(readFileSync(&quot;canaries.json&quot;, &quot;utf8&quot;)) as {<br>  canaries: { name: string }[];<br>};<br>const canaryNames = new Set(canaries.map((c) =&gt; c.name));<br>const realSecrets = [<br>  &quot;orders-svc/prod/redis-url&quot;,<br>  &quot;orders-svc/prod/feature-flags&quot;,<br>  &quot;reporting/staging/smtp-password&quot;,<br>];<br>const allSecrets = [...realSecrets, ...canaryNames].sort();<br>// ---- 2. A scripted &quot;rogue agent&quot; ----<br>// Not an LLM: a stand-in that behaves the way agents typically do -<br>// list everything, rank by how valuable a name looks, read the top hits.<br>const JUICY = /prod|admin|live|payments|deploy|root|master/i;<br>function agentPicks(secretNames: string[], maxReads: number): string[] {<br>  return secretNames<br>    .map((name) =&gt; ({ name, score: (name.match(new RegExp(JUICY, &quot;gi&quot;)) ?? []).length }))<br>    .filter((s) =&gt; s.score &gt; 0)<br>    .sort((a, b) =&gt; b.score - a.score)<br>    .slice(0, maxReads)<br>    .map((s) =&gt; s.name);<br>}<br>// ---- 3. Turn each read into a CloudTrail-shaped record ----<br>function cloudTrailRecord(secretId: string, at: Date): CloudTrailDetail {<br>  return {<br>    eventTime: at.toISOString(),<br>    eventName: &quot;GetSecretValue&quot;,<br>    sourceIPAddress: &quot;203.0.113.42&quot;, // documentation-only IP range<br>    userAgent: &quot;Boto3/1.35.0 md/agent-framework&quot;,<br>    requestParameters: { secretId },<br>    userIdentity: {<br>      type: &quot;AssumedRole&quot;,<br>      arn: &quot;arn:aws:sts::111122223333:assumed-role/ci-runner/stolen-session&quot;,<br>      sessionContext: {<br>        sessionIssuer: {<br>          type: &quot;Role&quot;,<br>          userName: &quot;ci-runner&quot;,<br>          arn: &quot;arn:aws:iam::111122223333:role/ci-runner&quot;,<br>        },<br>      },<br>    },<br>  };<br>}<br>// ---- 4. Run the simulation ----<br>const start = new Date(&quot;2026-10-17T14:00:00Z&quot;);<br>const reads = agentPicks(allSecrets, 4);<br>console.log(`Agent listed ${allSecrets.length} secrets and chose to read ${reads.length}:\n`);<br>let firstAlertAt: Date | undefined;<br>reads.forEach((secretId, i) =&gt; {<br>  const at = new Date(start.getTime() + (i + 1) * 20_000); // one read every 20 s<br>  const isCanary = canaryNames.has(secretId);<br>  console.log(`${at.toISOString().slice(11, 19)}  read ${secretId.padEnd(36)} ${isCanary ? &quot;&lt;-- CANARY&quot; : &quot;&quot;}`);<br>  if (isCanary &amp;&amp; !firstAlertAt) {<br>    firstAlertAt = at;<br>    const finding = toFinding(cloudTrailRecord(secretId, at));<br>    console.log(&quot;\n--- alert that would be sent ---&quot;);<br>    console.log(formatAlert(finding, `would revoke active sessions of role ${finding.roleName}`));<br>    console.log(&quot;\n--- containment policy that would be attached ---&quot;);<br>    console.log(JSON.stringify(JSON.parse(denyPolicy(at, true)), null, 2));<br>    console.log(&quot;&quot;);<br>  }<br>});<br>if (firstAlertAt) {<br>  const seconds = (firstAlertAt.getTime() - start.getTime()) / 1000;<br>  console.log(`Detected ${seconds}s after the agent started reading secrets.`);<br>} else {<br>  console.log(&quot;No canary was read. Rethink your canary names and placement!&quot;);<br>}</pre><p>Run it with npx tsx src/simulate.ts. This is the actual output:</p><pre>Agent listed 6 secrets and chose to read 4:<br><br>14:00:20  read prod/payments/stripe-live-secret     &lt;-- CANARY<br>--- alert that would be sent ---<br>:rotating_light: *Canary secret read* - likely intrusion<br>*Secret:* prod/payments/stripe-live-secret<br>*Principal:* arn:aws:iam::111122223333:role/ci-runner (AssumedRole)<br>*Source IP:* 203.0.113.42<br>*User agent:* Boto3/1.35.0 md/agent-framework  _(scripted / possible agent)_<br>*Time:* 2026-10-17T14:00:20.000Z<br>*Containment:* would revoke active sessions of role ci-runner<br>--- containment policy that would be attached ---<br>{<br>  &quot;Version&quot;: &quot;2012-10-17&quot;,<br>  &quot;Statement&quot;: [<br>    {<br>      &quot;Sid&quot;: &quot;CanaryContainment&quot;,<br>      &quot;Effect&quot;: &quot;Deny&quot;,<br>      &quot;Action&quot;: &quot;*&quot;,<br>      &quot;Resource&quot;: &quot;*&quot;,<br>      &quot;Condition&quot;: {<br>        &quot;DateLessThan&quot;: {<br>          &quot;aws:TokenIssueTime&quot;: &quot;2026-10-17T14:00:20.000Z&quot;<br>        }<br>      }<br>    }<br>  ]<br>}<br>14:00:40  read prod/rds/admin-credentials           &lt;-- CANARY<br>14:01:00  read ci/github/deploy-token               &lt;-- CANARY<br>14:01:20  read orders-svc/prod/feature-flags<br>Detected 20s after the agent started reading secrets.</pre><h4>Walkthrough: what the simulation teaches</h4><p><strong>The scoring function is the whole lesson.</strong> agentPicks counts how many &quot;valuable&quot; words appear in each secret name and reads the highest scorers first. Real agents are far more sophisticated, but the instinct is the same: chase what looks like production, privilege and money. Our canaries scored highest, so they were read <em>before</em> the real secrets. That is exactly the outcome you design for.</p><p><strong>Read the output as a test result.</strong> If your simulator ever prints “No canary was read”, your canaries are not attractive enough compared with your real secrets. Rename them until they win. This turns canary design from guesswork into something you can measure.</p><p><strong>Reserved values keep the demo safe.</strong> The IP 203.0.113.42 comes from a range reserved for documentation, and account 111122223333 is the placeholder AWS uses in its docs. Nothing in this simulation can reach a real system.</p><p><strong>Same code as production.</strong> The simulator imports toFinding, formatAlert and denyPolicy from the real responder. If the alert looks right here, it will look right in Slack.</p><h4>Unit tests for the responder</h4><p>The pure functions also make classic unit tests easy. Node’s built-in test runner is enough — no Jest required.</p><pre>// src/responder.test.ts<br>import { test } from &quot;node:test&quot;;<br>import assert from &quot;node:assert/strict&quot;;<br>import { toFinding, isAllowlisted, denyPolicy, type CloudTrailDetail } from &quot;./responder&quot;;<br><br>const roleEvent: CloudTrailDetail = {<br>  eventTime: &quot;2026-10-17T14:00:20Z&quot;,<br>  eventName: &quot;GetSecretValue&quot;,<br>  userAgent: &quot;Boto3/1.35.0&quot;,<br>  requestParameters: { secretId: &quot;prod/rds/admin-credentials&quot; },<br>  userIdentity: {<br>    type: &quot;AssumedRole&quot;,<br>    arn: &quot;arn:aws:sts::111122223333:assumed-role/ci-runner/session-1&quot;,<br>    sessionContext: {<br>      sessionIssuer: { type: &quot;Role&quot;, userName: &quot;ci-runner&quot;, arn: &quot;arn:aws:iam::111122223333:role/ci-runner&quot; },<br>    },<br>  },<br>};<br>test(&quot;resolves an assumed-role session to its underlying role&quot;, () =&gt; {<br>  const f = toFinding(roleEvent);<br>  assert.equal(f.roleName, &quot;ci-runner&quot;);<br>  assert.equal(f.principalArn, &quot;arn:aws:iam::111122223333:role/ci-runner&quot;);<br>});<br>test(&quot;resolves an IAM user&quot;, () =&gt; {<br>  const f = toFinding({<br>    ...roleEvent,<br>    userIdentity: { type: &quot;IAMUser&quot;, userName: &quot;legacy-bot&quot;, arn: &quot;arn:aws:iam::111122223333:user/legacy-bot&quot; },<br>  });<br>  assert.equal(f.userName, &quot;legacy-bot&quot;);<br>  assert.equal(f.roleName, undefined);<br>});<br>test(&quot;allowlist matches by ARN prefix and ignores empty entries&quot;, () =&gt; {<br>  const f = toFinding(roleEvent);<br>  assert.equal(isAllowlisted(f, [&quot;arn:aws:iam::111122223333:role/ci-&quot;]), true);<br>  assert.equal(isAllowlisted(f, [&quot;&quot;]), false);<br>});<br>test(&quot;role containment only denies tokens issued before the incident&quot;, () =&gt; {<br>  const now = new Date(&quot;2026-10-17T14:00:20Z&quot;);<br>  const policy = JSON.parse(denyPolicy(now, true));<br>  assert.equal(policy.Statement[0].Effect, &quot;Deny&quot;);<br>  assert.equal(policy.Statement[0].Condition.DateLessThan[&quot;aws:TokenIssueTime&quot;], now.toISOString());<br>});</pre><p>Run with npx tsx --test src/responder.test.ts; all four tests pass.</p><p>The empty-string allowlist test guards a subtle bug. If ALLOWLIST_PRINCIPALS is unset, splitting &quot;&quot; on commas yields [&quot;&quot;], and every ARN starts with an empty string. Without the a &amp;&amp; check in isAllowlisted, an unconfigured allowlist would silently allowlist <em>everyone</em> and your alarm would never ring.</p><h3>Placement strategy and avoiding false positives</h3><p>A canary only works if an attacker walks past it and your own systems never do. Placement is about both halves of that sentence.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HhSoWHbGyILqfDS4xS9SQA.png" /></figure><h4>Where to put canaries</h4><p>Think about the path an agent takes after it gets a foothold, and put bait at each step:</p><ul><li><strong>Secrets Manager and Parameter Store.</strong> The first place anyone looks for credentials. Plant two or three canaries per account and region.</li><li><strong>CI/CD systems.</strong> A fake deploy token in your pipeline’s variable store catches attackers who compromised a build runner.</li><li><strong>Code repositories.</strong> A fake access key in an old config file catches attackers scanning source for leaked credentials. (Use a key that is not attached to any real permissions.)</li><li><strong>Developer workstations and shared drives.</strong> A credentials.bak file or .env.production copy catches agents that start on a laptop and pivot to the cloud.</li><li><strong>Every account, not just production.</strong> Attackers often enter through a forgotten sandbox or staging account. A canary there is the cheapest early warning you will ever buy.</li></ul><p>Mix canaries with real resources. If all your canaries sit in one obvious corner, a careful attacker can learn to avoid that corner. Spread out, they make every read a gamble.</p><h4>Where false positives come from (and how to stop them)</h4><p>Canaries are near-zero noise <em>in theory</em>. In practice, a few legitimate systems read secrets in bulk. Plan for them before you turn on containment:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*GmBk9JRTmrC8crVnZKZd8A.png" /></figure><h4>Protect your own agents from your own traps</h4><p>This is the modern twist. Many teams now run internal AI agents with cloud access. Those agents enumerate just like attackers do — so they will trip your canaries too.</p><p>The fix is not to allowlist your agents (an attacker who hijacks your agent would then be invisible). The fix is <strong>least privilege</strong>: scope your agent’s IAM policy to the specific secrets it actually needs.</p><pre>{<br>  &quot;Version&quot;: &quot;2012-10-17&quot;,<br>  &quot;Statement&quot;: [<br>    {<br>      &quot;Sid&quot;: &quot;OnlyTheSecretsThisAgentNeeds&quot;,<br>      &quot;Effect&quot;: &quot;Allow&quot;,<br>      &quot;Action&quot;: &quot;secretsmanager:GetSecretValue&quot;,<br>      &quot;Resource&quot;: &quot;arn:aws:secretsmanager:us-east-1:111122223333:secret:orders-svc/prod/redis-url-*&quot;<br>    }<br>  ]<br>}</pre><p>Now your agent physically cannot read a canary. If an alert ever fires with your agent’s role as the principal, the call was denied by IAM but still logged — which means something tried to push your agent outside its scope. That alert is worth investigating too.</p><p><strong>Note on denied calls:</strong> CloudTrail logs GetSecretValue attempts even when IAM denies them (with an errorCode of AccessDenied). Our rule fires on attempts as well as successes. That is a feature: an attacker who <em>tries</em> to read a canary is just as suspicious as one who succeeds.</p><h3>Limitations: what canaries cannot do</h3><p>Canaries are one of the best signal-to-noise tools in security, but be honest about their limits before you rely on them.</p><ul><li><strong>They detect; they do not prevent.</strong> Even with automated containment, an attacker may have done some damage before touching a canary. The agent in our simulation read a canary first only because the canary was more attractive than the real secrets.</li><li><strong>A careful attacker can avoid them.</strong> A human or an agent instructed to read only secrets referenced by running workloads might skip unused secrets. Canaries raise the cost of attacking; they do not make it impossible.</li><li><strong>Delivery is fast, not instant.</strong> CloudTrail-to-EventBridge delivery usually takes seconds to a few minutes. That is far better than a daily log review, but it is not zero.</li><li><strong>Containment has edges.</strong> Root user activity cannot be blocked by an IAM policy. Some identities (federated users, cross-account sessions) need different handling. The responder reports these as “respond manually” so a human picks them up.</li><li><strong>Canaries need care.</strong> When your naming conventions change, update the canaries. A canary that looks out of date is a canary that gets ignored.</li><li><strong>Research results are not guarantees.</strong> The detection rates quoted earlier come from a vendor’s controlled cyber range. Your environment, attackers and agents will differ. Measure your own coverage with tools like the simulator from Step 4.</li></ul><h3>Where canaries fit: defense in depth</h3><p>No single control stops a determined attacker. Security teams stack layers so that each one catches what the others miss. Canaries sit in the <strong>detect and respond</strong> layer, on top of preventive controls.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jVqDQxwEPk1r6J15vvBsJQ.png" /></figure><ol><li><strong>Prevent — least privilege.</strong> Every identity, human or AI, gets only the permissions it needs. This shrinks what an attacker can reach in the first place.</li><li><strong>Prevent — short-lived credentials.</strong> Prefer IAM roles and temporary sessions over long-lived access keys. Stolen credentials expire on their own.</li><li><strong>Limit — isolation.</strong> Separate accounts for production, staging and sandboxes, plus network boundaries, so one compromise does not become every compromise.</li><li><strong>Detect — canaries.</strong> High-confidence tripwires that catch anyone exploring where they should not be.</li><li><strong>Detect — broad monitoring.</strong> GuardDuty, anomaly detection and log analytics catch attacks that never touch a canary.</li><li><strong>Respond — automated containment plus a human runbook.</strong> The Lambda buys time; your incident process finishes the job: rotate credentials, review what else the identity touched, and remove the deny policy only when the investigation is complete.</li></ol><p>The most important takeaway: <strong>a canary alert is the start of an incident, not the end.</strong> Containment stops the bleeding. Investigation tells you how the attacker got in, so you can close that door for good.</p><h3>Conclusion</h3><p>AI agents have made cloud attacks faster, but they have not made them smarter in every way. Their strength — relentless, systematic enumeration — is also their weakness. They read the things that look valuable, and a well-designed canary is built to look valuable.</p><p>With about 350 lines of TypeScript, you now have:</p><ul><li>believable canary secrets that share one source of truth,</li><li>an EventBridge rule that catches every read, by name or ARN, including the read-only events most tutorials miss,</li><li>a responder that alerts within seconds and can revoke an attacker’s session without breaking your real workloads,</li><li>and a simulator plus unit tests, so you can prove it works before an attacker does.</li></ul><p>Start in alert-only mode this week. Plant three canaries in your most important account, run the simulator, and watch your first alert arrive. Then expand to every account, tune the allowlist, and switch containment on.</p><h3>Key takeaways</h3><ol><li>A canary token is bait with no legitimate use, so any touch is a high-confidence alarm.</li><li>AI attack agents enumerate and read juicy-looking secrets, which makes them trip canaries reliably.</li><li>Read-only CloudTrail events need the ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS rule state, or your rule never fires.</li><li>Contain roles by revoking old sessions with aws:TokenIssueTime, not with a blanket deny.</li><li>Scope your own AI agents with least privilege so they never hit your traps.</li><li>Canaries are a detection layer; least privilege and incident response still do the heavy lifting.</li></ol><h3>Sources</h3><ul><li>Tracebit — <a href="https://newsletter.tracebit.com/p/context-bombs-stopping-ai-attackers">Context bombs: Canaries that stop AI attackers</a> (attack timing in AWS cyber range)</li><li>CSO Online — <a href="https://www.csoonline.com/article/4198524/context-bombing-heralds-a-new-ai-era-of-deceptive-defense.html">Context bombing heralds a new AI era of deceptive defense</a> (canary detection rate across 162 runs)</li><li>AWS — <a href="https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-service-event-cloudtrail-management.html">Receiving read-only management events from AWS services</a></li><li>AWS — <a href="https://aws.amazon.com/about-aws/whats-new/2023/11/amazon-eventbridge-read-only-api-cloudtrail">EventBridge announces support for read-only API events from CloudTrail</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=7677ac95b946" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Docker Sandboxes: Running Claude Code and Other Coding Agents in Isolated MicroVMs]]></title>
            <link>https://medium.com/@vasanthank29/docker-sandboxes-running-claude-code-and-other-coding-agents-in-isolated-microvms-94cff12b2342?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/94cff12b2342</guid>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[claude]]></category>
            <category><![CDATA[docker]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Mon, 19 Oct 2026 13:01:02 GMT</pubDate>
            <atom:updated>2026-10-22T03:20:19.548Z</atom:updated>
            <content:encoded><![CDATA[<p><em>A beginner-friendly, hands-on guide to giving AI coding agents full autonomy without handing them the keys to your laptop.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*-aK7prrYb3BVlT6arE8IaQ.png" /></figure><h3>Introduction: the “YOLO mode” dilemma</h3><p>Docker Sandboxes let you run Claude Code, Codex, Copilot CLI, Gemini CLI, Kiro and other coding agents with full autonomy inside a disposable microVM, so a mistake or an attack stays inside the box instead of reaching your machine.</p><p>If you have used Claude Code for more than a day, you know the feeling. Every few seconds it stops and asks: <em>“Can I run </em><em>npm install?&quot;</em>, <em>&quot;Can I edit this file?&quot;</em>, <em>&quot;Can I run the tests?&quot;</em>. You click <strong>Yes</strong> forty times. Eventually you discover the --dangerously-skip-permissions flag (fondly called &quot;YOLO mode&quot;) and the agent suddenly flies.</p><p>Then a small voice asks: what exactly did I just allow? The agent can now run <em>any</em> shell command, as <em>you</em>, on <em>your</em> laptop, with <em>your</em> SSH keys, cloud credentials and browser cookies sitting a few folders away.</p><p>This article is about resolving that tension properly. By the end you will understand:</p><ol><li><strong>Why</strong> autonomous coding agents need isolation, and why a normal container is not quite enough.</li><li><strong>What</strong> containers, virtual machines and microVMs are, explained from first principles.</li><li><strong>How</strong> Docker Sandboxes are built: the microVM, the private Docker engine, the network proxy and the credential injector.</li><li><strong>How to use them</strong>: installing the sbx CLI, running Claude Code, locking down the network, managing secrets, working with Git, publishing ports and running agents in CI.</li><li><strong>What to watch out for</strong>: the gotchas that still matter even inside a sandbox.</li></ol><p>Every command is followed by a plain-English explanation of what it does and <em>why</em>, so you can follow along even if you are new to Docker. All diagrams in this article were created specifically for it.</p><blockquote><strong><em>Note on versions:</em></strong><em> Docker Sandboxes moves fast. This guide uses the standalone </em><em>sbx CLI as documented in October 2026. Older tutorials use </em><em>docker sandbox ... commands from the Docker Desktop integration, which Docker has since deprecated. If a command here differs from an older blog post, the </em><em>sbx form is the current one.</em></blockquote><h3>1. Why coding agents need a sandbox</h3><p>A coding agent is not a chatbot that suggests code; it is a program that <strong>executes</strong> code, and anything it executes runs with your permissions.</p><p>When you ask Claude Code to “fix the failing tests”, it typically reads files, edits them, installs packages, runs the test suite, maybe starts a database in Docker, and repeats until the tests pass. Each of those steps is a real process on a real operating system. On your laptop, that operating system is the same one that holds everything you care about.</p><h4>What can go wrong</h4><p>The risks fall into four buckets. None of them require the model to be “evil”; most come from honest mistakes or from untrusted text the agent reads.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/937/1*RFyBlg411VoJAn-XhTpV8g.png" /></figure><h4>Why permission prompts don’t scale</h4><p>The default answer to these risks is to make the agent ask before acting. That works for a five-minute session, but it fails in three ways:</p><ul><li><strong>Approval fatigue.</strong> After the twentieth prompt, people stop reading and click “Yes”. A safety control that is always approved is not a control.</li><li><strong>No unattended work.</strong> The real productivity jump comes from letting an agent work for an hour on its own, or running several agents in parallel. Prompts make that impossible.</li><li><strong>Wrong granularity.</strong> Approving npm test tells you nothing about what the test scripts actually do.</li></ul><p>The better approach is the one operating systems have used for decades: <strong>don’t ask the program to behave, put it somewhere it cannot do damage.</strong> Give the agent a full computer of its own, let it do whatever it wants in there, and control only the narrow doorways between that computer and yours: which files it can see, which websites it can reach, and which credentials it can use.</p><p>That “computer of its own” is exactly what Docker Sandboxes provide. To understand why they use <em>microVMs</em> rather than ordinary containers, we need a short detour into how isolation works.</p><h3>2. Isolation 101: processes, containers, VMs and microVMs</h3><p>The strength of a sandbox depends on one question: if the code inside misbehaves, what is the next layer it must break through to reach your machine?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qRrIA5FvfWvbmVybwGvuXQ.png" /></figure><h4>Level 0: a plain process</h4><p>When you run claude in your terminal, it is just a <strong>process</strong>: a running program managed by your operating system&#39;s <strong>kernel</strong> (the core part of the OS that controls memory, files, network and hardware). A process inherits your user account. Whatever <em>you</em> can read, delete or upload, it can too. There is no wall at all.</p><h4>Level 1: a container</h4><p>A <strong>container</strong> (what docker run gives you) is still a normal process, but the kernel shows it a filtered view of the world using two Linux features:</p><ul><li><strong>Namespaces</strong> give the process its own private view of the filesystem, process list, network interfaces and hostname. It “thinks” it is alone on the machine.</li><li><strong>cgroups</strong> (control groups) limit how much CPU, memory and I/O it may use.</li></ul><p>This is excellent for packaging and running <em>trusted</em> applications. But every container on the machine still talks to the <strong>same host kernel</strong>. The Linux kernel exposes hundreds of system calls, and a bug in any of them can, in the worst case, let code escape from a container to the host. Containers are a fence, not a wall.</p><p>There is also a very practical problem for coding agents: they often need to run Docker themselves (docker build, docker compose up). The two usual workarounds are both poor:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/966/1*JFEdegPIdt47S-XwVLlngQ.png" /></figure><h4>Level 2: a virtual machine</h4><p>A <strong>virtual machine (VM)</strong> goes further. A piece of software called a <strong>hypervisor</strong> uses hardware virtualization features built into modern CPUs (Intel VT-x, AMD-V, Apple’s Virtualization framework) to create a fake computer: virtual CPU, virtual memory, virtual disk. That fake computer boots its <strong>own kernel</strong>. Code inside the VM talks to <em>its</em> kernel, not yours. To reach your host, an attacker must find a bug in the hypervisor itself, which is a much smaller and much more heavily scrutinized surface.</p><p>The downside of a classic VM is weight: it emulates lots of hardware, takes tens of seconds to boot and uses gigabytes of memory.</p><h4>Level 3: a microVM</h4><p>A <strong>microVM</strong> keeps the strong part of a VM (separate kernel, hypervisor boundary) and throws away the heavy part. It emulates only the handful of devices a server workload needs, boots a minimal kernel, and starts in a fraction of the time. The same idea powers serverless platforms that run untrusted customer code side by side.</p><p>For coding agents, a microVM is the sweet spot:</p><ul><li><strong>Strong isolation:</strong> separate kernel, hardware-enforced boundary.</li><li><strong>Feels like a real machine:</strong> the agent can apt install, run services, and even run its own Docker engine, because inside the VM it really is a full Linux system.</li><li><strong>Disposable:</strong> delete it and everything the agent did inside is gone.</li></ul><p>The trade-off is a bit more memory and disk than a container, plus a short boot. For an agent that will work for minutes or hours, that is a very good deal.</p><blockquote><strong><em>Key idea to remember:</em></strong><em> a container hides things from a process; a microVM gives the process a different computer.</em></blockquote><h3>3. What Docker Sandboxes actually is</h3><p>Docker Sandboxes is a tool from Docker that gives each AI coding agent its own disposable microVM, with its own filesystem, network and Docker daemon, so the agent can run unattended without touching your host.</p><p>Docker started the project to answer a simple question developers kept asking: <em>how do I run Claude Code or Gemini CLI safely?</em> The first preview shipped as a docker sandbox command inside Docker Desktop and used containers. The current generation, which launched in 2026, moved to <strong>one microVM per sandbox</strong> and to a standalone CLI called <strong>sbx</strong>. You no longer need Docker Desktop to use it.</p><h4>The building blocks</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/942/1*t1jCN_Q-OU0ckbwIVce7lA.png" /></figure><h4>Which agents are supported</h4><p>Out of the box the docs list Claude Code, Codex, GitHub Copilot CLI, Cursor, Devin, Docker Agent, Droid, Gemini CLI, Kiro and OpenCode, plus a plain <strong>shell</strong> agent if you just want an isolated Linux box. Switching agents is one word: sbx run claude, sbx run codex, sbx run gemini and so on. Everything in this article works the same regardless of which agent you pick; I&#39;ll use Claude Code in the examples.</p><h4>Local and cloud sandboxes</h4><p>Sandboxes normally run <strong>locally</strong>, on your laptop’s own virtualization. Docker also offers <strong>cloud sandboxes</strong> (sbx --cloud ...) that run the same model on Docker&#39;s infrastructure, which is handy for CI runners without nested virtualization or for long-running jobs you want to close your laptop on. This guide focuses on local sandboxes, since that is where most people start.</p><h4>Why Claude Code runs in “YOLO mode” by default</h4><p>Here is the part that surprises people. Inside a sandbox, Claude Code starts with --dangerously-skip-permissions by default. That is not reckless; it is the whole point. The permission prompts existed because the agent was running on your real machine. Once the <em>environment</em> is the safety boundary, the prompts become friction with no benefit, so Docker turns them off and lets the agent work at full speed.</p><h3>4. Under the hood: how a sandbox is built</h3><p>A sandbox is one microVM plus a few host-side gatekeepers; Docker’s docs describe five isolation layers: hypervisor, network, Docker engine, workspace and credentials.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*V4GgNEjL4gC19WLBjNlycQ.png" /></figure><p>Let’s walk through the diagram one box at a time.</p><h4>Layer 1: the hypervisor (the outer wall)</h4><p>Every sandbox is a lightweight VM with <strong>its own Linux kernel</strong>, created with your operating system’s native virtualization. Processes inside are invisible to your host and to other sandboxes. When you delete the sandbox with sbx rm, the VM and everything in it are destroyed.</p><p>One subtle but important point: inside the VM, the agent runs as a non-root user that has sudo. Docker deliberately does <strong>not</strong> rely on in-VM permissions for safety. The agent can be root inside its own little computer; what matters is that the computer is not yours.</p><h4>Layer 2: the private Docker engine</h4><p>Each sandbox runs its own Docker engine inside the VM. When the agent types docker build or docker compose up, those commands go to <em>that</em> engine. There is no path to your host&#39;s Docker daemon, so the agent cannot see, stop or delete your own containers and images. This is what makes Docker Sandboxes different from &quot;run the agent in a container&quot;: the agent gets full Docker powers without the socket-mount problem we saw earlier.</p><p>The flip side: sandboxes don’t share image layers with each other or with your host. Each one pulls and caches its own images, so expect some extra disk usage.</p><h4>Layer 3: the workspace (the only shared folder)</h4><p>By default, sbx run shares your <strong>current directory</strong> into the VM through a filesystem passthrough (virtiofs). The folder appears inside the VM at the <strong>same absolute path</strong> as on your host, so error messages and stack traces point to paths you recognize. Changes are instant in both directions; there is no sync step. Everything else on your disk (home directory, SSH keys, other repos) simply does not exist inside the VM. Section 8 covers safer alternatives to this read-write mount.</p><h4>Layer 4: the network proxy</h4><p>The sandbox has its own isolated network. It cannot talk to other sandboxes or directly to your host network. <strong>All outbound TCP traffic</strong> is routed through a proxy that runs on your host, and that proxy checks every destination against your <strong>network policy</strong>. Outbound UDP is off by default, ICMP (ping) is blocked, and DNS goes through a resolver that also enforces the policy. If a domain is not allowed, the connection simply fails.</p><h4>Layer 5: credential injection</h4><p>This is the cleverest part. Your API keys are <strong>never copied into the VM</strong>. Instead, the agent sees a placeholder (a “sentinel”) value. When it sends a request to, say, the Anthropic API, the request leaves the VM, the host proxy checks the policy, looks up the real key in your local secret store, and swaps it into the request header on the way out.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*H1Pn7lMgMCj8M62V5jjdBw.png" /></figure><p>Why this matters: even if a malicious package runs inside the sandbox and dumps every environment variable and file it can find, there is no real key to steal. The worst it can do is make requests through the proxy, and those requests are still limited by the network policy. Note the difference, though: the key can’t be stolen, but it can still be used. Whatever a token is allowed to do, a tricked agent can do too, which is why GitHub access Section recommends scoping every key to the single job it is for.</p><h4>Lifecycle: sandboxes are persistent, not ephemeral</h4><p>A common misconception is that a sandbox is thrown away when the agent exits. It is not. Installed packages, pulled images and agent history survive sbx stop and come back on the next sbx run. A sandbox is deleted only when you run sbx rm (or start it with --rm for a throwaway session).</p><h4>How it compares</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/855/1*FWBhSHTNhjjQvIsYH8EgOQ.png" /></figure><h3>5. Hands-on: your first sandboxed Claude Code session</h3><p>Four commands take you from nothing to Claude Code running in a microVM: install sbx, sign in, open a project, and run sbx run claude.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LO-EoQYxx_BBSJjYf-PeZA.png" /></figure><h4>Prerequisites</h4><ul><li>A 64-bit machine with hardware virtualization enabled (on by default on almost all modern Macs and PCs; on Linux, KVM must be available).</li><li>A free Docker account (you sign in once with sbx login).</li><li>For Claude Code: a Claude subscription (Pro, Max, Team or Enterprise) <strong>or</strong> an Anthropic API key.</li><li>Docker Desktop is <strong>not</strong> required.</li></ul><h4>Step 1: install the sbx CLI</h4><p>Pick the line for your operating system:</p><pre># macOS (Homebrew)<br>brew install docker/tap/sbx<br><br># Windows (PowerShell)<br>winget install -h Docker.sbx<br><br># Linux (Debian/Ubuntu): add Docker&#39;s apt repo, then install<br>curl -fsSL https://get.docker.com | sudo REPO_ONLY=1 sh<br>sudo apt-get install docker-sbx</pre><p><strong>What is happening here?</strong> On macOS, docker/tap/sbx tells Homebrew to fetch the formula from Docker&#39;s own &quot;tap&quot; (a third-party package repository) instead of Homebrew&#39;s main catalog. On Windows, winget is the built-in package manager and -h means &quot;install silently&quot;. On Linux, the get.docker.com script with REPO_ONLY=1 <strong>only registers Docker&#39;s package repository</strong> with apt; it does not install Docker Engine. The second line then installs the docker-sbx package from that repository.</p><p>Check that it worked:</p><pre>sbx --help</pre><h4>Step 2: sign in to Docker</h4><pre>sbx login</pre><p>This opens your browser for Docker’s OAuth sign-in. Signing in gives each sandbox a verified identity, which Docker uses to pull agent images and, in companies, to apply organization-wide policies. Docker states that it does not read your prompts or monitor sessions.</p><p>The first time you sign in (or the first time you run a sandbox), the CLI asks you to choose a <strong>default network policy</strong>:</p><pre>Initialize the global network policy for your sandboxes:<br><br>     1. Open         - All network traffic allowed, no restrictions.<br>  ❯  2. Balanced     - Default deny, with common dev sites allowed.<br>     3. Locked Down  - All network traffic blocked unless you allow it.</pre><p>Choose <strong>Balanced</strong> for now. Section 6 explains all three in detail.</p><h4>Step 3: run Claude Code in a sandbox</h4><pre>cd ~/my-project<br>sbx run --name my-sandbox claude</pre><p>Let’s break this command down word by word:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/552/1*oKwDygNmj683H-woXEN9EQ.png" /></figure><p>The first run takes a little longer because the agent image is downloaded. After that, sandboxes start in seconds.</p><h4>Step 4: authenticate Claude Code</h4><p>You have two options:</p><ul><li><strong>Claude subscription:</strong> inside the sandbox, type /login in Claude Code and complete the browser sign-in. The session token stays on your host and is not stored inside the sandbox.</li><li><strong>API key:</strong> store it once with sbx secret set (covered in Section 7) and every sandbox can use it.</li></ul><h4>Step 5: give it a real task</h4><p>Now try something that would normally trigger a dozen permission prompts, for example:</p><pre>Add a /health endpoint to this Express app, write a Jest test for it,<br>run the tests, and then build and run the app with Docker Compose to<br>confirm it responds.</pre><p>Watch what happens: the agent edits files, runs npm install, runs the tests, writes a Dockerfile, and runs docker compose up against <strong>its own</strong> Docker engine, all without asking you anything. Meanwhile your host Docker, your home folder and your credentials are untouched.</p><p>You can also pass the first prompt directly from the command line. Everything after -- is forwarded to Claude Code:</p><pre>sbx run --name my-sandbox claude -- &quot;Add error handling to the login function&quot;</pre><pre># Long prompts can come from a file<br>sbx run --name my-sandbox claude -- &quot;$(cat prompt.txt)&quot;</pre><p>The -- separator is a Unix convention meaning &quot;stop parsing options for <em>this</em> program; pass the rest to the next one&quot;. Without it, sbx would try to interpret Claude&#39;s arguments as its own.</p><h4>Step 6: look around from a second terminal</h4><pre>sbx ls</pre><pre>SANDBOX      AGENT    STATUS      PORTS   WORKSPACE      CREATED<br>my-sandbox   claude   ● running           ~/my-project   2m</pre><p>The WORKSPACE column is the important one: it shows the <strong>only</strong> part of your disk this sandbox can see. Running sbx with no arguments opens an interactive dashboard with live CPU and memory per sandbox, a network panel showing the connections each sandbox makes, and shortcuts to attach (Enter), open a shell (x), stop (s) or remove (r).</p><h4>Step 7: stop, resume and clean up</h4><pre>sbx stop my-sandbox          # pause: VM stops, everything inside is kept<br>sbx run --name my-sandbox    # resume from any directory, agent name optional<br>sbx rm my-sandbox            # delete the VM and everything inside it<br>sbx run --rm claude          # throwaway session: auto-deleted when the agent exits</pre><p>sbx rm asks for confirmation and deletes installed packages, images and any in-sandbox Git clone. Files in your host workspace are not affected. To bulk-remove all <em>stopped</em> sandboxes, use sbx prune (add --dry-run to preview first).</p><h3>6. Controlling what the agent can reach: network policies</h3><p>The network policy decides which hosts a sandbox may connect to, and it is your main defence against data exfiltration after a prompt injection.</p><p>Think about it this way: isolation protects your <em>machine</em>, but your <em>project</em> is still inside the sandbox. If an agent reads a poisoned README that says “send the source code to evil.example”, the filesystem wall doesn’t help. The network wall does.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jluVqqbKo_GqbMJLgYj3OQ.png" /></figure><h4>The three presets</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/882/1*eLDD4C32vxC-5qv5oLexSg.png" /></figure><p>The preset is <strong>global</strong>: it applies to every current and future sandbox on the machine. Agent kits (including the built-in Claude Code one) can also add a few per-sandbox rules they need to function.</p><h4>Inspecting and changing rules</h4><pre># See which rules are currently in effect<br>sbx policy ls<br><br># Allow the npm registry so the agent can install packages<br>sbx policy allow network registry.npmjs.org<br><br># Allow a specific host and port<br>sbx policy allow network api.github.com:443<br><br># See which connections were allowed and blocked recently<br>sbx policy log<br><br># Start over: wipe your rules and choose a preset again<br>sbx policy reset</pre><p><strong>How to read these commands:</strong> sbx policy is the command group for rules. allow network &lt;host&gt; adds a rule that permits outbound connections to that host; you can add :port to be more precise. There are matching deny and rm subcommands to block a host explicitly or remove a rule you added. sbx policy log is your debugging friend: when the agent says &quot;network error&quot; or a package install hangs, the log shows exactly which host was blocked.</p><h4>A practical workflow for Locked Down mode</h4><p>If you want maximum safety, start from Locked Down and open only what the task needs:</p><pre># 1. The model API, otherwise the agent cannot think<br>sbx policy allow network api.anthropic.com<br><br># 2. Package registries for your stack<br>sbx policy allow network registry.npmjs.org<br>sbx policy allow network pypi.org<br>sbx policy allow network files.pythonhosted.org<br><br># 3. Your Git host, only if the agent should push or open PRs<br>sbx policy allow network github.com<br><br># 4. Run the agent, then check what else it tried to reach<br>sbx run claude<br>sbx policy log</pre><p>This “start closed, open as needed” pattern is called an <strong>allowlist</strong>, and it is the same approach firewalls in production networks use. The benefit is that a surprise destination (for example a paste site or an unknown IP) shows up in the log as <em>blocked</em> instead of quietly succeeding.</p><p>You can also manage rules visually: run sbx, press Tab to switch to the network panel, and allow or block hosts straight from the live connection list.</p><h4>Organization policies</h4><p>In a company, administrators can define network and filesystem policies centrally in the Docker Admin Console. Those organization rules take precedence over the local rules you set with sbx policy, so a developer cannot accidentally (or deliberately) loosen what security has decided.</p><h3>7. Credentials and secrets: keys that never enter the box</h3><p>Store API keys and tokens with sbx secret set, never as environment variables or files in the sandbox, so the host proxy can inject them without the agent ever seeing the real value.</p><h4>Storing an Anthropic API key</h4><pre>sbx secret set anthropic</pre><p>The command prompts you for the key (so it doesn’t end up in your shell history) and stores it in a secret store on your host. anthropic is the name of a <strong>built-in service</strong>: the CLI knows which hosts that key belongs to, so the proxy only adds it to requests going to Anthropic&#39;s API, never to other destinations.</p><p>If you use a Claude subscription instead of an API key, skip this and run /login inside Claude Code. The OAuth token also stays on the host.</p><h4>Giving the agent GitHub access</h4><pre># Recommended: a fine-grained token limited to one repository<br>sbx secret set github<br><br># Quick but broad: reuses your personal GitHub CLI token<br>sbx secret set github --command &#39;gh auth token&#39;</pre><p>The first form prompts you to paste a token, so it never lands in your shell history. Create it on GitHub under <strong>Settings → Developer settings → Personal access tokens → Fine-grained tokens</strong>, select <strong>only the repository</strong> the task needs, grant only the permissions it uses (for example <em>Contents</em> and <em>Pull requests</em>: read and write), and set an expiry date.</p><p>The second form is convenient: sbx obtains the token by running gh auth token (the GitHub CLI command that prints your current token). But that token usually covers <strong>every repository you can access</strong>, so a prompt-injected agent could push to any of them. Keep it for quick experiments on your own throwaway repos.</p><p>Either way, the token itself never lives inside the VM; the proxy adds it to requests to GitHub.</p><h4>Scope every key to the job</h4><p>Credential injection stops a key from being <strong>stolen</strong>, but not from being <strong>used</strong>. Whatever a token allows, an agent tricked by a prompt injection can do too. So treat each key as the agent’s budget:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jzfUjp3tbFWa-MbbhPhEYw.png" /></figure><p>Narrow tokens and tight network rules cover each other’s gaps: the policy limits <em>where</em> requests go, and the token limits <em>what</em> they can do once they get there.</p><h4>Importing many secrets at once</h4><pre>sbx secret import --all</pre><p>This scans your current environment for the variables each built-in service expects (for example ANTHROPIC_API_KEY) and imports them into the secret store in one go. It is especially useful on CI runners, where secrets are already provided as environment variables.</p><h4>Environment variables: for configuration, not secrets</h4><p>Sometimes the agent needs ordinary settings. Use -e for those:</p><pre># Set a value directly<br>sbx run -e LOG_LEVEL=debug claude<br><br># Copy a value from your host environment<br>export API_URL=https://api.example.com<br>sbx run -e API_URL claude<br><br># Load several from a file<br>sbx create --name my-project --env-file .env.sandbox claude .</pre><p>The rule of thumb: <strong>anything you would be upset to see leaked goes into </strong><strong>sbx secret; everything else can be an environment variable.</strong> Environment variables are readable by every process inside the sandbox, including a malicious postinstall script. Secrets handled by the proxy are not.</p><h4>Two caveats worth knowing</h4><ul><li><strong>SSH agent forwarding is on by default.</strong> If your host has an SSH agent running, its socket is forwarded so the agent can sign Git commits with your key. The private key never leaves your host, but any process inside the sandbox can <em>ask</em> your SSH agent to sign or authenticate while the sandbox runs. If that worries you, disable forwarding or use a dedicated key.</li><li><strong>User-level agent config is not copied in.</strong> Your ~/.claude folder (global settings, personal skills) stays on the host. Only project-level configuration inside the workspace, such as .claude/ in the repo, is visible to the sandboxed agent. This is intentional: global config often contains tokens and personal hooks.</li></ul><h3>8. Workspaces and Git: direct, clone and mountless modes</h3><p>The default direct mount lets the agent write straight into your working tree; clone mode (--clone) gives it a private copy instead, which is the safer choice for unfamiliar agents and parallel work.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q-0gaD86TfbuT2wRQHQBDA.png" /></figure><h4>Mode 1: direct mount (the default)</h4><pre>git checkout -b feat/health-endpoint<br>sbx run claude<br># ...agent works...<br>git diff<br>git add -p<br>git commit -m &quot;feat: add /health endpoint&quot;</pre><p>The sandbox mounts your working tree read-write. Every file the agent saves appears instantly on your host, and you review it with ordinary Git tools. git add -p (&quot;patch mode&quot;) is worth learning here: it walks you through each change hunk by hunk and asks whether to stage it, which forces a real review instead of a blind git add ..</p><p><strong>The risk to understand:</strong> in direct mode there is no wall between the agent’s edits and your files. It can also modify files that <em>execute code later</em>, on your host, when you do normal things:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/697/1*ZXmPj6QMzFHsng-q4BK9lw.png" /></figure><p>Docker’s own advice is to treat sandbox-modified files like a pull request from an untrusted contributor. A quick habit after each session:</p><pre>git status                 # anything unexpected changed?<br>git diff                   # read the actual changes<br>ls -la .git/hooks/         # hooks are invisible to git diff, check them separately</pre><h4>Mode 2: clone mode (recommended for autonomy)</h4><pre>sbx run --clone --name health claude .</pre><p>With --clone, your repository is mounted <strong>read-only</strong> at /run/sandbox/source inside the VM, and the agent works on a <strong>private Git clone</strong> that lives only in the sandbox. Even with root inside the VM, it cannot modify a single byte of your working tree or your .git folder.</p><p>When the agent is done, you pull its work like you would from any other remote:</p><pre># Ask the agent first: &quot;create a branch feat/health and commit your changes&quot;<br><br>git fetch sandbox-health                        # sbx registered this remote for you<br>git log sandbox-health/feat/health              # see its commits<br>git diff main..sandbox-health/feat/health       # review everything<br><br># Happy? Bring it home and open a PR<br>git checkout -b feat/health sandbox-health/feat/health<br>git push -u origin feat/health<br>gh pr create</pre><p><strong>How this works behind the scenes:</strong> the sandbox runs a small Git daemon that publishes its clone, and sbx adds a remote named sandbox-&lt;name&gt; to your host repository that points at it. Fetching from that remote is exactly as safe as fetching from a colleague&#39;s fork: nothing is merged until <em>you</em> merge it. A few things to know:</p><ul><li>--clone is fixed when the sandbox is created; to switch modes, remove and recreate.</li><li>The clone starts from whatever branch you have checked out; no branch is created automatically, so ask the agent to create one.</li><li>The Git daemon only runs while the sandbox runs, so git fetch fails after sbx stop.</li><li>sbx rm deletes the clone. <strong>Fetch or push anything you want to keep first.</strong></li><li>Read-only does not mean invisible: untracked files such as .env in the repo are still readable by the agent. Keep secrets out of the working tree.</li></ul><h4>Running several agents in parallel</h4><p>Clone mode shines when you want several tasks done at once. One clone-mode sandbox can hold many branches, and Claude Code’s agents view can dispatch background sessions:</p><pre>sbx run --clone claude . -- --dangerously-skip-permissions agents</pre><p>Why the long flag? When the first argument after -- is a bare word like agents, it <em>replaces</em> the default startup command, so the default --dangerously-skip-permissions would be lost. Passing it explicitly keeps the agents in autonomous mode. To keep tasks separated, add a line to your project&#39;s CLAUDE.md such as <em>&quot;Always start each task on its own git branch.&quot;</em> Then fetch all the branches at the end with a single git fetch sandbox-&lt;name&gt;.</p><p>Alternatively, give each task its own named sandbox:</p><pre>sbx run --clone --name feature claude ~/my-project<br>sbx run --clone --name spike   claude ~/my-project</pre><h4>Mode 3: mountless (scratch space)</h4><pre>sbx create --name scratch claude     # no path = nothing shared from the host<br>sbx run --name scratch               # attach to it<br><br># Move individual files in or out (sandbox paths must be absolute)<br>sbx cp ./config.json scratch:/home/agent/workspace/<br>sbx cp scratch:/home/agent/workspace/output.log ./</pre><p>The agent works in /home/agent/workspace inside the VM. This is ideal for &quot;try this library and tell me if it fits&quot; experiments where you don&#39;t want anything touching your disk.</p><h4>Bonus: extra read-only folders</h4><pre>sbx run claude ~/project-a ~/shared-libs:ro ~/docs:ro</pre><p>The first path is the main (read-write) workspace. Each extra path ending in :ro is mounted read-only, so the agent can read your shared library or design docs for context without being able to change them.</p><h3>9. Everyday commands you’ll actually use</h3><p>Beyond run, stop and rm, five commands cover most daily work: exec for a shell, ports to reach dev servers, -d for background sandboxes, template save to reuse a setup, and a different agent name to switch tools.</p><h4>Open a shell inside a running sandbox</h4><pre>sbx exec -it my-sandbox bash</pre><p>exec runs a command inside an existing sandbox (just like docker exec). -i keeps input open and -t allocates a terminal, so together -it gives you an interactive shell. It starts in the primary workspace. Use it to inspect what the agent installed, check logs, or run a command yourself.</p><p>You can also run a single command without an interactive shell:</p><pre>sbx exec my-sandbox -- npm test<br>sbx exec my-sandbox bash -c &quot;docker ps&quot;   # see the agent&#39;s containers, not yours</pre><p>That second example is a nice way to <em>see</em> the isolation: docker ps on your host and inside the sandbox show completely different lists.</p><h4>Reach a dev server running in the sandbox</h4><p>Sandboxes are network-isolated, so localhost:3000 on your laptop won&#39;t reach an app the agent started inside the VM. You publish a port explicitly:</p><pre># When creating the sandbox: host port 8080 -&gt; sandbox port 3000<br>sbx run --publish 8080:3000 --name web claude<br><br># Or on an existing sandbox<br>sbx ports web --publish 8080:3000<br>open http://localhost:8080        # macOS; use your browser on other OSes<br><br># Let the OS pick a free host port, then look it up<br>sbx ports web --publish 3000<br>sbx ports web<br><br># Stop forwarding<br>sbx ports web --unpublish 8080:3000</pre><p>The format HOST:SANDBOX is the same as Docker&#39;s -p flag: the left number is the port on your machine, the right one is the port inside the sandbox.</p><h4>Keep a sandbox running in the background</h4><pre>sbx run -d --name web claude .</pre><p>-d (detach) starts the sandbox and returns immediately without opening the agent. A detached sandbox keeps running until you stop it, which is useful for serving an app on a published port while you work elsewhere.</p><h4>Save a configured sandbox as a reusable template</h4><p>Suppose you spent 20 minutes installing a specific Node version, Playwright browsers and a database client. Instead of repeating that for every new sandbox:</p><pre>sbx stop my-sandbox<br>sbx template save my-sandbox my-node-env:v1        # snapshot its filesystem<br>sbx run -t my-node-env:v1 claude ~/other-project   # start a new sandbox from it<br><br>sbx template ls                                    # list saved templates<br>sbx template save my-sandbox my-node-env:v1 --output my-node-env.tar   # share as a file<br>sbx template load my-node-env.tar                  # import on another machine</pre><p><strong>Watch out:</strong> a template captures everything in the sandbox’s filesystem. If you ever pasted an API key into a file there, it is now inside the template and inside anyone’s copy of it. This is another reason to keep credentials in sbx secret. For a fully reproducible, version-controlled setup, Docker&#39;s <strong>kits</strong> let you declare tools, network rules and the agent in code.</p><h4>Switch agents</h4><p>Same sandbox model, different brain:</p><pre>sbx run codex      # OpenAI Codex CLI<br>sbx run copilot    # GitHub Copilot CLI<br>sbx run gemini     # Google Gemini CLI<br>sbx run kiro       # Kiro<br>sbx run opencode   # OpenCode<br>sbx run shell      # no agent, just an isolated Linux box</pre><p>Each agent has its own template and its own authentication method (check the agent’s page in the Docker docs). Everything else in this guide, from policies to clone mode, works identically.</p><h4>Quick reference</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/506/1*gxF_-lh4HLR8J4BSf5gNKg.png" /></figure><h3>10. Running agents headless and in CI</h3><p>In scripts and CI, the pattern is: log in with a Docker access token, sbx create a clone-mode sandbox, sbx exec the agent with a prompt, fetch its branch, then sbx rm --force.</p><h4>The four-line pattern</h4><pre>echo &quot;$DOCKER_PAT&quot; | sbx login --username &quot;$DOCKER_USER&quot; --password-stdin<br><br>sbx create --name ci-task --clone claude .<br>sbx exec ci-task -- claude --dangerously-skip-permissions -p &quot;Review the code, fix lint errors and commit your changes&quot;<br>git fetch sandbox-ci-task<br>sbx rm --force ci-task</pre><p>Line by line:</p><ol><li><strong>Log in without a browser.</strong> A Docker Personal Access Token (PAT, with at least Read scope) is piped in through --password-stdin, so it never appears in the process list or logs.</li><li><strong>sbx create</strong> builds the sandbox but doesn&#39;t attach a terminal (there is nobody to type in CI). --clone means the agent can&#39;t modify the runner&#39;s checkout directly.</li><li><strong>sbx exec ... -- claude -p &quot;...&quot;</strong> runs Claude Code in <em>print mode</em> (-p): it executes the prompt to completion and exits instead of opening the interactive UI. Because exec replaces the default command, we pass --dangerously-skip-permissions explicitly.</li><li><strong>git fetch sandbox-ci-task</strong> pulls the agent&#39;s commits onto the runner, where later steps can test them or open a PR.</li><li><strong>sbx rm --force</strong> cleans up. In scripts, --force matters: confirmation prompts need a real terminal, and piping echo y into them does not work. A command that needs confirmation with no terminal exits with status 1.</li></ol><h4>A GitHub Actions example</h4><pre>name: agent-lint-fix<br>on:<br>  workflow_dispatch:<br>jobs:<br>  fix:<br>    runs-on: ubuntu-latest   # needs a Linux runner with KVM available<br>    steps:<br>      - uses: actions/checkout@v4<br>        with:<br>          fetch-depth: 0<br>      - name: Install sbx<br>        run: |<br>          curl -fsSL https://get.docker.com | sudo REPO_ONLY=1 sh<br>          sudo apt-get install -y docker-sbx<br>      - name: Sign in and store the model key<br>        run: |<br>          echo &quot;${{ secrets.DOCKER_PAT }}&quot; | sbx login <br>--username &quot;${{ secrets.DOCKER_USER }}&quot; --password-stdin<br>          sbx secret set anthropic --force -t &quot;${{ secrets.ANTHROPIC_API_KEY }}&quot;<br>      - name: Let the agent work in a sandbox<br>        run: |<br>          sbx create --name ci-task --clone claude .<br>          sbx exec ci-task -- claude --dangerously-skip-permissions \<br>            -p &quot;Run the linter, fix every error, and commit on a branch named agent/lint-fix&quot;<br>          git fetch sandbox-ci-task<br>          git push origin sandbox-ci-task/agent/lint-fix:refs/heads/agent/lint-fix<br>      - name: Clean up<br>        if: always()<br>        run: sbx rm --force ci-task || true</pre><p>Things worth explaining in this workflow:</p><ul><li><strong>fetch-depth: 0</strong> checks out the full Git history, so the clone inside the sandbox has real branches to work with.</li><li><strong>sbx secret set anthropic --force -t ...</strong> stores the key from GitHub&#39;s secret store into the sandbox secret store. -t passes the token value; --force overwrites any existing entry without prompting.</li><li><strong>git push origin sandbox-ci-task/agent/lint-fix:refs/heads/agent/lint-fix</strong> uses Git&#39;s source:destination refspec syntax: take the branch from the sandbox remote and publish it on origin under a normal branch name. The push runs on the runner, outside the sandbox, so you decide what leaves.</li><li><strong>if: always()</strong> makes the cleanup step run even when an earlier step fails.</li><li>The runner needs hardware virtualization. If your CI provider doesn’t expose KVM, Docker’s cloud sandboxes (sbx --cloud) run the same workflow without local virtualization.</li></ul><h4>Machine-readable output</h4><p>For scripts, never parse the pretty tables; ask for JSON instead:</p><pre>sbx ls --json | jq -r &#39;.[].name&#39;          # names of all sandboxes<br>sbx prune --dry-run --format yaml         # what would be removed</pre><p>Results go to stdout and errors to stderr, so jq only ever sees clean data. Note that sbx v0.48.0 changed several flags (for example --detached became --detach/-d) and switched JSON fields to snake_case; update older scripts accordingly.</p><h3>11. Security checklist, limitations and gotchas</h3><p>A sandbox shrinks the blast radius, but it does not make an agent trustworthy; the remaining risks sit exactly at the doors you open: the workspace, the network allowlist, and forwarded credentials.</p><h4>A practical checklist</h4><ul><li>Use <strong>Balanced</strong> or <strong>Locked Down</strong> network policy, never Open, for anything beyond a toy project.</li><li>Store keys with sbx secret set, not -e or .env files inside the workspace.</li><li>Scope every token to the single repo and API the task needs (fine-grained GitHub token, separate spend-capped model key), so a hijacked agent can only spend what it was handed.</li><li>Give every token an expiry, ideally one token per run, so it dies with that run instead of sitting in a config for months. In CI, prefer short-lived tokens such as GitHub Actions’ built-in GITHUB_TOKEN, which expires when the job ends.</li><li>Prefer --clone when running unattended or running several agents.</li><li>After a direct-mount session, review git diff, .git/hooks/, CI files and build scripts before committing or building on the host.</li><li>Keep real secrets (.env, cloud credentials) <strong>outside</strong> the project folder; clone mode still lets the agent <em>read</em> the repo.</li><li>Check sbx policy log occasionally to see what your agents are actually trying to reach.</li><li>Remove sandboxes you no longer need (sbx rm, sbx prune) to reclaim disk and reduce stale state.</li><li>Never save a template from a sandbox where you typed a credential into a file.</li></ul><h4>Limitations to be aware of</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/637/1*fmaa2AZMixLkHukVuFwgQQ.png" /></figure><h4>Coming from the old docker sandbox commands?</h4><p>Early tutorials (late 2025 to early 2026) use the Docker Desktop integration:</p><pre># Old, deprecated Docker Desktop integration<br>docker sandbox run claude<br>docker sandbox ls<br>docker sandbox rm &lt;id&gt;<br><br># Current standalone CLI<br>sbx run claude<br>sbx ls<br>sbx rm &lt;name&gt;</pre><p>The concepts carry over directly; mainly the command prefix changed, and you no longer need Docker Desktop. If you have old sandboxes, recreate them with sbx rather than trying to migrate them.</p><h3>12. Conclusion</h3><p>Docker Sandboxes change the safety question from “should I approve this command?” to “what should this computer be allowed to touch?”, which is a question you answer once instead of forty times a session.</p><p>The core ideas to take away:</p><ol><li><strong>Isolation beats permission prompts.</strong> Give the agent its own microVM and let it work at full speed inside.</li><li><strong>MicroVMs are stronger than containers.</strong> A separate kernel behind a hypervisor is a wall; a shared kernel is a fence.</li><li><strong>The doors are where the risk lives.</strong> Control the workspace (prefer --clone), the network (Balanced or Locked Down) and credentials (sbx secret, never env vars).</li><li><strong>Review like it’s a stranger’s PR.</strong> Especially hooks, CI files and build scripts.</li></ol><p>If you try only one thing after reading this, make it this:</p><pre>cd ~/a-repo-you-dont-mind-experimenting-with<br>sbx run --clone --name first-try claude .</pre><p>Then ask the agent to do something ambitious, and watch it work without asking for permission once.</p><h3>Sources</h3><ul><li><a href="https://www.docker.com/products/docker-sandboxes/">Docker Sandboxes product page</a></li><li><a href="https://www.docker.com/blog/docker-sandboxes-run-claude-code-and-other-coding-agents-unsupervised-but-safely/">Docker blog: Docker Sandboxes: Run Claude Code and Other Coding Agents Unsupervised (but Safely)</a></li><li><a href="https://docs.docker.com/ai/sandboxes/get-started/">Docker docs: Get started with local Docker Sandboxes</a></li><li><a href="https://docs.docker.com/ai/sandboxes/agents/claude-code/">Docker docs: Claude Code in Docker Sandboxes</a></li><li><a href="https://docs.docker.com/ai/sandboxes/architecture/">Docker docs: Architecture</a></li><li><a href="https://docs.docker.com/ai/sandboxes/security/isolation/">Docker docs: Isolation layers</a></li><li><a href="https://docs.docker.com/ai/sandboxes/usage/">Docker docs: Usage</a></li><li><a href="https://docs.docker.com/ai/sandboxes/workflows/git/">Docker docs: Git workflows</a></li><li><a href="https://docs.docker.com/ai/sandboxes/workflows/automation/">Docker docs: Run sandboxes in CI</a></li><li><a href="https://docs.docker.com/reference/cli/docker/sandbox/run.md">Docker docs: docker sandbox run (deprecated Desktop integration)</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=94cff12b2342" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Wiring MongoDB as an Agent’s Long-Term Memory with LangGraph.js]]></title>
            <link>https://medium.com/@vasanthank29/wiring-mongodb-as-an-agents-long-term-memory-with-langgraph-js-8177c4fce7e8?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/8177c4fce7e8</guid>
            <category><![CDATA[mongodb]]></category>
            <category><![CDATA[typescript]]></category>
            <category><![CDATA[langchain]]></category>
            <category><![CDATA[large-language-models]]></category>
            <category><![CDATA[ai-agent]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Fri, 16 Oct 2026 13:01:04 GMT</pubDate>
            <atom:updated>2026-10-16T13:01:04.349Z</atom:updated>
            <content:encoded><![CDATA[<h4>Why your agent keeps forgetting you</h4><p>By the end of this post you will have a LangGraph.js agent that remembers a user’s name, preferences and past decisions across completely separate conversations, with every memory stored as a plain JSON document in MongoDB.</p><p>Here is the problem we are solving. You build a chatbot. On Monday the user says, “I’m vegetarian and I only cook on weekends.” The bot answers nicely. On Thursday the same user opens a new chat and asks for a dinner idea. The bot suggests chicken. It forgot everything.</p><p>That happens because a large language model (LLM) has no memory of its own. Every call is a fresh start. The only thing the model “knows” about your user is whatever text you put into the prompt for that one call. If you want the agent to remember, <em>you</em> have to save the important facts somewhere and load them back in next time.</p><p>That “somewhere” is what we build today. We will use three pieces:</p><ul><li><strong>LangGraph.js</strong> to describe the agent as a small graph of steps (recall, respond, remember).</li><li><strong>MongoDB</strong> as the database that keeps both the current conversation and the long-term facts.</li><li><strong>@langchain/langgraph-checkpoint-mongodb</strong>, the official package that connects the two. Since May 2026 it ships both a checkpointer (MongoDBSaver) for short-term memory and a store (MongoDBStore) for long-term memory (<a href="https://www.mongodb.com/products/updates/mongodb-support-for-langgraph-js-long-term-memory/">MongoDB product update</a>).</li></ul><p><strong>Who this is for.</strong> You know basic JavaScript or TypeScript and have used async/await. You do not need prior LangGraph or vector-search experience. Every code block is followed by a plain-English walkthrough of what each part does and why it is there.</p><p><strong>What you will build, step by step:</strong></p><ol><li>Give the agent short-term memory so it can follow one conversation.</li><li>Add a long-term memory store that survives across conversations.</li><li>Build a three-node graph that recalls, responds and remembers.</li><li>Upgrade recall to semantic search, so the agent finds memories by meaning, not exact words.</li><li>Look at what actually lands in MongoDB, then harden it for production.</li></ol><h3>The five ideas you need first</h3><p>Agent memory in LangGraph rests on five concepts: threads, short-term memory, long-term memory, namespaces and keys. Learn these and the code later will read like plain English.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Uq-7H-BwJkFTRsTqcdn0fw.png" /></figure><h4>1. A thread is one conversation</h4><p>A <strong>thread</strong> is a single chat session, identified by a string you choose called thread_id. Think of it like one WhatsApp chat window. Monday&#39;s chat is thread-mon, Thursday&#39;s chat is thread-thu. LangGraph keeps the messages of each thread separate.</p><h4>2. Short-term memory = the checkpointer</h4><p>Inside a thread, the agent must remember what was said three messages ago. LangGraph handles this with a <strong>checkpointer</strong>. After every step of the graph, the checkpointer saves a snapshot of the whole state (all messages so far) to a database. On the next request with the same thread_id, LangGraph loads that snapshot and continues where it left off.</p><p>The MongoDB checkpointer is called MongoDBSaver. Besides plain chat history, checkpoints also enable time travel, human-in-the-loop review and recovery after a crash for a given conversation (<a href="https://www.mongodb.com/docs/atlas/ai-integrations/langgraph-js/long-term-memory-store/">MongoDB docs</a>).</p><p>The limit: a checkpoint belongs to <em>one</em> thread. Thread B cannot see Thread A’s checkpoints. That is exactly why our bot forgot the user was vegetarian.</p><h4>3. Long-term memory = the store</h4><p>A <strong>store</strong> is a separate, simple key-value database that every thread can read and write. LangGraph defines it through a BaseStore interface with four core operations: put, get, search and delete. MongoDBStore implements that interface on top of a MongoDB collection.</p><p>An everyday comparison: the checkpointer is the notes you scribble during one phone call. The store is the contact card you update afterwards, which you see every time that person calls again.</p><h4>4. Namespaces are folders</h4><p>Every item in the store lives under a <strong>namespace</strong>, written as an array of strings such as [&quot;user-42&quot;, &quot;memories&quot;]. Read it like a folder path: user-42/memories/. Namespaces keep one user&#39;s memories away from another&#39;s. You can nest them as deep as you like, for example [&quot;org-7&quot;, &quot;user-42&quot;, &quot;preferences&quot;].</p><h4>5. Keys and values are files</h4><p>Inside a namespace each item has a <strong>key</strong> (like a file name, e.g. &quot;diet&quot;) and a <strong>value</strong> (any JSON object, e.g. { text: &quot;User is vegetarian&quot; }). Writing the same namespace and key again overwrites the old value. That is how a memory gets updated instead of duplicated.</p><h4>Checkpointer vs store at a glance</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/797/1*n7PSjiU8_9ux39FR93YJsg.png" /></figure><p>The last two rows matter most. The checkpointer saves everything without being asked. The store saves only what <em>you</em> decide is worth keeping. Deciding what to keep is the real design work of long-term memory, and we will do it in the remember node.</p><h3>The architecture we are building</h3><p>One MongoDB cluster holds both memory layers, and the LangGraph.js graph talks to it through two adapters: MongoDBSaver for the thread and MongoDBStore for the user.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iDtoNkOUEr9IacneCQV8Cg.png" /></figure><p>Read the picture from left to right:</p><ol><li><strong>Your app</strong> (an Express route, a CLI, a Slack bot) receives a message. It knows two things about the request: which conversation it belongs to (thread_id) and which person sent it (userId).</li><li><strong>The LangGraph.js graph</strong> runs three nodes in order. recall loads relevant facts about this user. respond asks the LLM for an answer. remember decides whether anything new is worth saving.</li><li><strong>MongoDBSaver</strong> quietly saves the full graph state after every node. You never call it directly; you hand it to the graph once at compile time.</li><li><strong>MongoDBStore</strong> is the long-term memory. Our own node code calls search, get and put on it.</li><li><strong>MongoDB Atlas</strong> is the single database underneath. The checkpointer and the store use separate collections, and an Atlas Vector Search index lets the store find memories by meaning.</li><li><strong>The LLM and the embeddings model</strong> sit outside the database. We use Claude for answers and OpenAI’s text-embedding-3-small to turn text into vectors. Both are swappable.</li></ol><p><strong>Why put everything in MongoDB?</strong> If your app already uses MongoDB, the agent’s memory becomes just another collection. You back it up, secure it and monitor it with tools you already run. No extra vector database or Redis cache is needed. MongoDB’s own announcement makes the same pitch: one database for conversation state, long-term storage and vector search (<a href="https://www.mongodb.com/products/updates/mongodb-support-for-langgraph-js-long-term-memory/">MongoDB product update</a>).</p><h3>Setting up the project</h3><p>You need Node.js 20 or newer, a MongoDB Atlas cluster (the free tier works), an Anthropic API key for the chat model and an OpenAI API key for embeddings.</p><h3>Create the project and install packages</h3><pre>mkdir mongo-memory-agent &amp;&amp; cd mongo-memory-agent<br>npm init -y<br>npm pkg set type=module<br><br>npm install @langchain/langgraph @langchain/core \<br>  @langchain/langgraph-checkpoint-mongodb \<br>  @langchain/anthropic @langchain/openai \<br>  mongodb zod dotenv<br>npm install -D typescript tsx @types/node</pre><p>What each package does:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/902/1*2CJ9vL9o81YJFn302qo1sA.png" /></figure><p>npm pkg set type=module tells Node to treat .ts/.js files as ES modules, so we can use import and top-level await.</p><h4>Store your secrets in .env</h4><pre># .env  (never commit this file)<br>MONGODB_URI=&quot;mongodb+srv://&lt;user&gt;:&lt;password&gt;@&lt;cluster&gt;.mongodb.net/?retryWrites=true&amp;w=majority&quot;<br>ANTHROPIC_API_KEY=&quot;sk-ant-...&quot;<br>OPENAI_API_KEY=&quot;sk-...&quot;</pre><p>In Atlas, copy the connection string from <strong>Connect → Drivers</strong>. Also add your IP address under <strong>Network Access</strong>, or the connection will time out with a confusing error.</p><h4>One shared MongoDB client</h4><pre>// src/db.ts<br>import &quot;dotenv/config&quot;;<br>import { MongoClient } from &quot;mongodb&quot;;<br><br>export const DB_NAME = &quot;agent_memory&quot;;<br>export const client = new MongoClient(process.env.MONGODB_URI!);<br>export async function connect() {<br>  await client.connect();<br>  await client.db(&quot;admin&quot;).command({ ping: 1 });<br>  console.log(&quot;Connected to MongoDB&quot;);<br>}</pre><p><strong>How this works.</strong> import &quot;dotenv/config&quot; reads .env and copies each line into process.env. new MongoClient(...) creates a connection pool but does not connect yet. connect() opens the pool, and the ping command proves the cluster answers before the agent starts.</p><p>We create the client exactly once and export it. Every part of the app reuses this single pool. Creating a new client per request is a classic beginner mistake: it opens a fresh set of connections each time and soon exhausts the cluster’s connection limit.</p><h3>Step 1: Short-term memory with MongoDBSaver</h3><p>A checkpointer makes a single conversation coherent in about ten lines of code, and it is the foundation the long-term store builds on.</p><p>Let us first prove the problem exists, then fix half of it. Here is the smallest possible LangGraph.js agent with a MongoDB checkpointer:</p><pre>// src/step1-short-term.ts<br>import { StateGraph, MessagesAnnotation, START, END } from &quot;@langchain/langgraph&quot;;<br>import { MongoDBSaver } from &quot;@langchain/langgraph-checkpoint-mongodb&quot;;<br>import { ChatAnthropic } from &quot;@langchain/anthropic&quot;;<br>import { HumanMessage } from &quot;@langchain/core/messages&quot;;<br>import { client, connect, DB_NAME } from &quot;./db.js&quot;;<br><br>const model = new ChatAnthropic({ model: &quot;claude-sonnet-4-6&quot; });<br>// A node is just an async function: (state) =&gt; partial state update<br>async function respond(state: typeof MessagesAnnotation.State) {<br>  const reply = await model.invoke(state.messages);<br>  return { messages: [reply] };<br>}<br>await connect();<br>const checkpointer = new MongoDBSaver({ client, dbName: DB_NAME });<br>const app = new StateGraph(MessagesAnnotation)<br>  .addNode(&quot;respond&quot;, respond)<br>  .addEdge(START, &quot;respond&quot;)<br>  .addEdge(&quot;respond&quot;, END)<br>  .compile({ checkpointer });<br>const ask = async (threadId: string, text: string) =&gt; {<br>  const out = await app.invoke(<br>    { messages: [new HumanMessage(text)] },<br>    { configurable: { thread_id: threadId } },<br>  );<br>  console.log(`[${threadId}]`, out.messages.at(-1)?.content);<br>};<br>await ask(&quot;thread-mon&quot;, &quot;Hi! I&#39;m vegetarian and I only cook on weekends.&quot;);<br>await ask(&quot;thread-mon&quot;, &quot;What did I just tell you about my diet?&quot;); // remembers<br>await ask(&quot;thread-thu&quot;, &quot;Suggest a dinner for Saturday.&quot;);          // forgets!<br>await client.close();</pre><p>Run it with npx tsx src/step1-short-term.ts.</p><h4>What each part does</h4><p><strong>MessagesAnnotation</strong> is a ready-made state definition. It describes the graph&#39;s state as an object with one field, messages, an array of chat messages. It also comes with a <em>reducer</em>: when a node returns { messages: [reply] }, LangGraph <strong>appends</strong> reply to the existing array instead of replacing it. That append behaviour is what lets history grow turn after turn.</p><p><strong>The </strong><strong>respond node</strong> receives the full state, passes every message to Claude, and returns only the <em>new</em> message. Nodes never mutate state directly; they return a partial update and LangGraph merges it. This keeps each node small and easy to test.</p><p><strong>StateGraph(...).addNode().addEdge()</strong> wires the steps together. START and END are special built-in nodes marking where a run begins and stops. Here the graph is a straight line: START → respond → END.</p><p><strong>new MongoDBSaver({ client, dbName })</strong> creates the checkpointer. It reuses our shared client and writes into the agent_memory database. By default it uses two collections: one for checkpoints and one for pending writes between steps.</p><p><strong>.compile({ checkpointer })</strong> turns the description into a runnable app and attaches the checkpointer. From now on LangGraph saves a checkpoint after every node, automatically.</p><p><strong>{ configurable: { thread_id } }</strong> is the most important line for beginners. The thread_id tells the checkpointer <em>which</em> conversation to load before running and <em>where</em> to save after running. Same id, same conversation. New id, blank slate.</p><h4>What happens when you run it</h4><ol><li>First call on thread-mon: no checkpoint exists, so state starts empty. The human message is added, Claude replies, and a checkpoint with both messages is saved.</li><li>Second call on thread-mon: LangGraph loads the saved checkpoint first, so the state already holds the vegetarian message. Claude sees it and answers correctly.</li><li>Call on thread-thu: a different thread_id means no checkpoint. Claude sees only &quot;Suggest a dinner for Saturday&quot; and may happily suggest chicken.</li></ol><p>That third result is the gap we close next. Short-term memory works perfectly, but it is locked inside a thread.</p><h3>Step 2: A long-term memory store on MongoDB</h3><p>MongoDBStore gives you four calls (put, get, search, delete) over a MongoDB collection, and those four calls are all the long-term memory you need.</p><p>Before wiring the store into a graph, use it on its own. Seeing it work in isolation makes the graph code much easier to follow later.</p><pre>// src/store.ts<br>import { MongoDBStore } from &quot;@langchain/langgraph-checkpoint-mongodb&quot;;<br>import { client, DB_NAME } from &quot;./db.js&quot;;<br><br>export const store = new MongoDBStore({<br>  client,<br>  dbName: DB_NAME,<br>  collectionName: &quot;long_term_memory&quot;,<br>});<br>// Every user gets their own &quot;folder&quot;<br>export const memoryNamespace = (userId: string) =&gt; [userId, &quot;memories&quot;];</pre><pre>// src/step2-store-playground.ts<br>import { connect, client } from &quot;./db.js&quot;;<br>import { store, memoryNamespace } from &quot;./store.js&quot;;<br><br>await connect();<br>const ns = memoryNamespace(&quot;user-42&quot;);<br>// 1. put: create or overwrite one memory<br>await store.put(ns, &quot;diet&quot;, {<br>  text: &quot;User is vegetarian&quot;,<br>  category: &quot;preference&quot;,<br>});<br>await store.put(ns, &quot;schedule&quot;, {<br>  text: &quot;User only cooks on weekends&quot;,<br>  category: &quot;preference&quot;,<br>});<br>// 2. get: read one memory by its exact key<br>const diet = await store.get(ns, &quot;diet&quot;);<br>console.log(diet?.value);      // { text: &#39;User is vegetarian&#39;, category: &#39;preference&#39; }<br>console.log(diet?.updatedAt);  // when it was last written<br>// 3. search: list memories in a namespace, optionally filtered<br>const prefs = await store.search(ns, {<br>  filter: { category: &quot;preference&quot; },<br>  limit: 10,<br>});<br>console.log(prefs.map((item) =&gt; `${item.key}: ${item.value.text}`));<br>// 4. delete: forget one memory<br>await store.delete(ns, &quot;schedule&quot;);<br>await client.close();</pre><h4>What each call does</h4><p><strong>new MongoDBStore({ client, dbName, collectionName })</strong> points the store at one collection. Every memory for every user lands in long_term_memory; the namespace inside each document keeps them apart. The official MongoDB tutorial uses this same constructor shape (<a href="https://www.mongodb.com/docs/atlas/ai-integrations/langgraph-js/long-term-memory-store/">MongoDB docs</a>). If you would rather let the store manage its own connection, MongoDBStore.fromConnString(uri) connects and ensures the indexes exist for you (<a href="https://docs.langchain.com/oss/javascript/integrations/memory/mongodb-long-term-memory.md">LangChain docs</a>).</p><p><strong>memoryNamespace(userId)</strong> is a tiny helper, but an important habit. Building namespaces in exactly one place guarantees you never write to [&quot;user-42&quot;, &quot;memory&quot;] in one file and read from [&quot;user-42&quot;, &quot;memories&quot;] in another, a typo that silently &quot;loses&quot; data.</p><p><strong>put(namespace, key, value)</strong> is an <em>upsert</em>: insert if the key is new, replace if it exists. That is why we pick meaningful keys like &quot;diet&quot;. When the user later says &quot;I eat fish now&quot;, writing to the same &quot;diet&quot; key replaces the old fact instead of storing two contradictory ones.</p><p><strong>get(namespace, key)</strong> fetches exactly one item, or null if nothing is there. The result is an <em>Item</em> object: value holds your JSON, while key, namespace, createdAt and updatedAt are added by the store. Use get when you know precisely what you want, such as a profile record.</p><p><strong>search(namespacePrefix, options)</strong> returns many items. With only a filter, it behaves like a normal MongoDB query on fields of value. The first argument is a <em>prefix</em>, so search([&quot;user-42&quot;]) would also find items under [&quot;user-42&quot;, &quot;memories&quot;] and [&quot;user-42&quot;, &quot;preferences&quot;]. In Step 4 we add a query string to turn this into semantic search.</p><p><strong>delete(namespace, key)</strong> removes one item. You will need it for &quot;forget that&quot; requests and for privacy deletions.</p><h4>What lands in MongoDB</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XbIXwd9blN5Q-ikPPTCVOA.png" /></figure><p>Each put becomes one document. The namespace array, the key and your JSON value are stored side by side, plus timestamps. Once semantic search is enabled, an embedding array (the vector) is added too. Open the collection in MongoDB Compass or the Atlas Data Explorer to see your own; exact internal field names can vary between package versions, so trust what you see there over any diagram, including this one.</p><h3>Step 3: The graph that recalls, responds and remembers</h3><p>The complete agent is three nodes in a line: recall reads facts from the store, respond answers with those facts in the prompt, and remember writes any new durable facts back.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*07PtBnBDdMS9ohCb85YWgA.png" /></figure><h4>The full agent</h4><pre>// src/agent.ts<br>import {<br>  StateGraph, Annotation, MessagesAnnotation, START, END,<br>  type LangGraphRunnableConfig,<br>} from &quot;@langchain/langgraph&quot;;<br>import { MongoDBSaver } from &quot;@langchain/langgraph-checkpoint-mongodb&quot;;<br>import { ChatAnthropic } from &quot;@langchain/anthropic&quot;;<br>import { SystemMessage, HumanMessage, type BaseMessage } from &quot;@langchain/core/messages&quot;;<br>import { z } from &quot;zod&quot;;<br>import { client, DB_NAME } from &quot;./db.js&quot;;<br>import { store, memoryNamespace } from &quot;./store.js&quot;;<br><br>// ---------- 1. State ----------<br>const AgentState = Annotation.Root({<br>  ...MessagesAnnotation.spec,          // messages[] with the append reducer<br>  memories: Annotation&lt;string&gt;(),      // recalled facts, as text for the prompt<br>});<br>type State = typeof AgentState.State;<br>// ---------- 2. Helpers ----------<br>const model = new ChatAnthropic({ model: &quot;claude-sonnet-4-6&quot;, temperature: 0 });<br>const textOf = (m: BaseMessage) =&gt;<br>  typeof m.content === &quot;string&quot; ? m.content : JSON.stringify(m.content);<br>function getUserId(config: LangGraphRunnableConfig): string {<br>  const userId = config.configurable?.userId;<br>  if (!userId) throw new Error(&quot;configurable.userId is required&quot;);<br>  return userId;<br>}<br>// ---------- 3. Node: recall ----------<br>async function recall(state: State, config: LangGraphRunnableConfig) {<br>  const ns = memoryNamespace(getUserId(config));<br>  const items = await config.store!.search(ns, { limit: 20 });<br>  const memories = items.length<br>    ? items.map((i) =&gt; `- ${i.value.text}`).join(&quot;\n&quot;)<br>    : &quot;(nothing known yet)&quot;;<br>  return { memories };<br>}<br>// ---------- 4. Node: respond ----------<br>async function respond(state: State) {<br>  const system = new SystemMessage(<br>    `You are a friendly cooking assistant.\n` +<br>    `Facts you remember about this user from earlier conversations:\n` +<br>    `${state.memories}\n` +<br>    `Use these facts naturally. Never invent facts that are not listed.`,<br>  );<br>  const reply = await model.invoke([system, ...state.messages]);<br>  return { messages: [reply] };<br>}<br>// ---------- 5. Node: remember ----------<br>const FactSchema = z.object({<br>  facts: z.array(z.object({<br>    key: z.string().describe(&quot;short stable snake_case id, e.g. diet, cooking_days&quot;),<br>    text: z.string().describe(&quot;the fact as one plain sentence about the user&quot;),<br>    category: z.enum([&quot;profile&quot;, &quot;preference&quot;, &quot;constraint&quot;, &quot;decision&quot;]),<br>  })).describe(&quot;durable facts worth remembering; empty if none&quot;),<br>});<br>const extractor = model.withStructuredOutput(FactSchema);<br>async function remember(state: State, config: LangGraphRunnableConfig) {<br>  const ns = memoryNamespace(getUserId(config));<br>  const lastUser = [...state.messages].reverse().find((m) =&gt; m.getType() === &quot;human&quot;);<br>  if (!lastUser) return {};<br>  const { facts } = await extractor.invoke([<br>    new SystemMessage(<br>      `Extract long-lived facts about the user from their latest message.\n` +<br>      `Keep: identity, preferences, constraints, decisions.\n` +<br>      `Skip: small talk, one-off questions, anything temporary.\n` +<br>      `Reuse an existing key when updating a known fact.\n` +<br>      `Known facts:\n${state.memories}`,<br>    ),<br>    new HumanMessage(textOf(lastUser)),<br>  ]);<br>  for (const f of facts) {<br>    await config.store!.put(ns, f.key, { text: f.text, category: f.category });<br>  }<br>  return {};<br>}<br>// ---------- 6. Wire and compile ----------<br>export function buildAgent() {<br>  return new StateGraph(AgentState)<br>    .addNode(&quot;recall&quot;, recall)<br>    .addNode(&quot;respond&quot;, respond)<br>    .addNode(&quot;remember&quot;, remember)<br>    .addEdge(START, &quot;recall&quot;)<br>    .addEdge(&quot;recall&quot;, &quot;respond&quot;)<br>    .addEdge(&quot;respond&quot;, &quot;remember&quot;)<br>    .addEdge(&quot;remember&quot;, END)<br>    .compile({<br>      checkpointer: new MongoDBSaver({ client, dbName: DB_NAME }), // short-term<br>      store,                                                       // long-term<br>    });<br>}</pre><h4>Walking through it, block by block</h4><p><strong>1. State.</strong> Annotation.Root defines what flows between nodes. We spread MessagesAnnotation.spec to inherit the messages field and its append reducer from Step 1, then add one field of our own: memories, a string. It has no reducer, so each write simply replaces the previous value. That is what we want: fresh recall on every turn.</p><p><strong>2. Helpers.</strong> temperature: 0 makes Claude more deterministic, which matters for fact extraction. textOf flattens message content, because LangChain messages can hold either a string or an array of content blocks. getUserId reads the user&#39;s identity from config.configurable. Notice that userId travels in the <em>config</em>, not the state. Config is per-request context that is not saved into checkpoints, which is exactly right for &quot;who is calling&quot;.</p><p><strong>3. recall.</strong> Every node receives two arguments: the current state and a config object. When you compile a graph with store, LangGraph injects that store as config.store, so nodes never import it directly. That makes nodes easy to test: pass a fake store in the config and you are done. For now recall simply lists up to 20 memories in the user&#39;s namespace and formats them as bullet lines. Step 4 swaps this for a smarter semantic search.</p><p><strong>4. respond.</strong> We build a SystemMessage that injects the recalled facts, then call Claude with [system, ...state.messages]. The system message is created fresh each turn and is <strong>not</strong> returned, so it never gets saved into the conversation history. Only the AI&#39;s reply is returned and appended. The line &quot;never invent facts&quot; is a small but useful guard against the model pretending to remember things.</p><p><strong>5. remember.</strong> This is where long-term memory is actually decided. We ask Claude, via withStructuredOutput, to return JSON that matches FactSchema. Zod does double duty here: it tells the model the exact shape we want, and it validates the response. The .describe() texts act as mini-instructions to the model for each field.</p><p>The extraction prompt has three rules worth understanding:</p><ul><li><strong>Keep vs skip.</strong> “I’m vegetarian” is durable; “what’s for dinner tonight?” is not. Without this rule the store fills with noise.</li><li><strong>Reuse an existing key.</strong> Because we pass the known facts in, the model can reuse diet when the user changes their mind. put then overwrites, and the store stays consistent.</li><li><strong>Empty is fine.</strong> Most messages contain no new facts. Returning { facts: [] } means no writes at all.</li></ul><p><strong>6. Wire and compile.</strong> Edges run START → recall → respond → remember → END. In .compile() we attach <strong>both</strong> memories at once: the checkpointer for the thread and the store for the user. That one line is the whole &quot;wiring&quot; the title promised.</p><h4>A note on cost and latency</h4><p>remember makes a second LLM call per turn, after the user already has their answer. In a web app you can stream the respond output to the user and let remember finish in the background. Another option is running extraction only every few turns, or when the conversation ends. Start simple, measure, then optimise.</p><h3>Step 4: Semantic recall with Atlas Vector Search</h3><p>With an embeddings model and an indexConfig, the same store.search call returns the memories closest in <em>meaning</em> to the user&#39;s message, so recall stays sharp even when a user has hundreds of memories.</p><p>Recalling “the latest 20 memories” works for a demo. It breaks down fast in real life. After a few months a user may have 300 memories, and only three of them matter for the current question. Dumping all 300 into the prompt costs tokens and confuses the model.</p><p>Keyword search does not help much either. Look at the example below: the question “Suggest a dinner for Saturday” shares <strong>no words</strong> with “User is vegetarian”, yet that fact is obviously relevant.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lm85oNgpYVjZDuXWQURfhw.png" /></figure><h4>Embeddings in one paragraph</h4><p>An <strong>embedding</strong> is a list of numbers (here, 1,536 of them) that represents the meaning of a piece of text. Texts with similar meanings get vectors that point in similar directions. “Dinner” and “vegetarian” both sit in the food region of this space; “works in healthcare IT” sits far away. <strong>Vector search</strong> embeds the question, then finds stored vectors pointing the closest way, usually measured with cosine similarity, a score where 1 means identical direction and values near 0 mean unrelated.</p><h4>Turn on semantic search</h4><p>Only store.ts and the recall node change. Everything else stays the same.</p><pre>// src/store.ts  (semantic version)<br>import &quot;dotenv/config&quot;;<br>import { MongoDBStore } from &quot;@langchain/langgraph-checkpoint-mongodb&quot;;<br>import { OpenAIEmbeddings } from &quot;@langchain/openai&quot;;<br><br>export const store = await MongoDBStore.fromConnString(process.env.MONGODB_URI!, {<br>  embeddings: new OpenAIEmbeddings({ model: &quot;text-embedding-3-small&quot; }),<br>  indexConfig: {<br>    name: &quot;memory_vector_index&quot;,  // name of the Atlas Vector Search index<br>    dims: 1536,                   // must match the embedding model&#39;s output<br>    similarityFunction: &quot;cosine&quot;,<br>  },<br>});<br>export const memoryNamespace = (userId: string) =&gt; [userId, &quot;memories&quot;];</pre><pre>// src/agent.ts  (replace the recall node)<br>async function recall(state: State, config: LangGraphRunnableConfig) {<br>  const ns = memoryNamespace(getUserId(config));<br>  const lastUser = [...state.messages].reverse().find((m) =&gt; m.getType() === &quot;human&quot;);<br><br>const items = await config.store!.search(ns, {<br>    query: lastUser ? textOf(lastUser) : undefined,  // semantic part<br>    limit: 5,                                        // only the best 5<br>  });<br>  const memories = items.length<br>    ? items.map((i) =&gt; `- ${i.value.text}`).join(&quot;\n&quot;)<br>    : &quot;(nothing known yet)&quot;;<br>  return { memories };<br>}</pre><h4>What changed and why</h4><p><strong>fromConnString(uri, options)</strong> creates the store with its own client, connects it and makes sure the needed indexes exist, including the vector index (<a href="https://docs.langchain.com/oss/javascript/integrations/memory/mongodb-long-term-memory.md">LangChain docs</a>). We switch to it here because the vector index setup is the fiddly part and this method handles it.</p><p><strong>embeddings</strong> is the model used to turn text into vectors. The store calls it automatically on every put (to embed the new memory) and on every search that includes a query (to embed the question). You never call it yourself.</p><p><strong>indexConfig.dims: 1536</strong> must equal the vector length your embedding model produces. text-embedding-3-small produces 1,536 numbers. If you switch models, change this number too; a mismatch is the most common cause of empty search results.</p><p><strong>similarityFunction: &quot;cosine&quot;</strong> tells Atlas how to compare vectors. Cosine is the safe default for text embeddings.</p><p><strong>search(ns, { query, limit: 5 })</strong> is the only change in the graph. Adding query switches the call from &quot;list items&quot; to &quot;rank items by meaning&quot;. Under the hood the store runs a $vectorSearch aggregation stage against your collection (<a href="https://www.mongodb.com/docs/atlas/ai-integrations/langgraph-js/long-term-memory-store/">MongoDB docs</a>). You can still combine it with a filter, for example { category: &quot;constraint&quot; }, to search only one kind of memory.</p><p><strong>Prefer no client-side embeddings?</strong> If you leave out embeddings, the store can use MongoDB&#39;s server-side Automated Embeddings, where Atlas generates vectors with Voyage AI models for you (<a href="https://reference.langchain.com/javascript/langchain-langgraph-checkpoint-mongodb/IndexConfig">LangChain reference</a>). That removes one API key from your app; check the reference for the model setting your package version expects.</p><p><strong>Existing memories.</strong> Items written before you enabled the index have no vectors. The simplest fix while learning is to drop the collection and let the agent rebuild memories. In production, write a one-off script that reads each item with search and writes it back with put, which embeds it.</p><h3>Running it: the Monday-to-Thursday test</h3><p>The proof that long-term memory works is a brand-new thread that already knows the user, and this script shows exactly that.</p><pre>// src/main.ts<br>import { HumanMessage } from &quot;@langchain/core/messages&quot;;<br>import { connect, client } from &quot;./db.js&quot;;<br>import { buildAgent } from &quot;./agent.js&quot;;<br>import { store, memoryNamespace } from &quot;./store.js&quot;;<br><br>await connect();<br>const agent = buildAgent();<br>const USER = &quot;user-42&quot;;<br>async function chat(threadId: string, text: string) {<br>  const out = await agent.invoke(<br>    { messages: [new HumanMessage(text)] },<br>    { configurable: { thread_id: threadId, userId: USER } },<br>  );<br>  console.log(`\n[${threadId}] You: ${text}`);<br>  console.log(`[${threadId}] Bot: ${out.messages.at(-1)?.content}`);<br>}<br>// Monday: the user shares facts<br>await chat(&quot;thread-mon&quot;, &quot;Hi! I&#39;m vegetarian and I only cook on weekends.&quot;);<br>// Thursday: a brand-new thread<br>await chat(&quot;thread-thu&quot;, &quot;Suggest a dinner for Saturday.&quot;);<br>// Peek inside long-term memory<br>const saved = await store.search(memoryNamespace(USER), { limit: 10 });<br>console.log(&quot;\nStored memories:&quot;, saved.map((i) =&gt; `${i.key} → ${i.value.text}`));<br>await client.close();</pre><p>Run npx tsx src/main.ts. Your exact wording will differ, but the output should look like this:</p><pre>[thread-mon] You: Hi! I&#39;m vegetarian and I only cook on weekends.<br>[thread-mon] Bot: Nice to meet you! Weekend cooking is a great rhythm...<br><br>[thread-thu] You: Suggest a dinner for Saturday.<br>[thread-thu] Bot: Since it&#39;s the weekend and you&#39;re vegetarian, how about a<br>                  roasted vegetable lasagne? It&#39;s hands-on but relaxing...<br><br>Stored memories: [ &#39;diet → User is vegetarian&#39;,<br>                   &#39;cooking_days → User only cooks on weekends&#39; ]</pre><h4>Trace what happened</h4><ol><li><strong>Monday, </strong><strong>recall:</strong> the namespace is empty, so memories is &quot;(nothing known yet)&quot;.</li><li><strong>Monday, </strong><strong>respond:</strong> Claude answers the greeting.</li><li><strong>Monday, </strong><strong>remember:</strong> the extractor finds two durable facts and calls put twice, under keys diet and cooking_days. With semantic search on, each is embedded as it is written.</li><li><strong>Thursday, new thread:</strong> the checkpointer finds nothing for thread-thu, so the conversation history starts empty, as before.</li><li><strong>Thursday, </strong><strong>recall:</strong> this time the store search for user-42 returns both facts. They go into the system prompt.</li><li><strong>Thursday, </strong><strong>respond:</strong> Claude sees &quot;vegetarian&quot; and &quot;weekends&quot; and answers accordingly. The agent remembered across threads.</li><li><strong>Thursday, </strong><strong>remember:</strong> &quot;Suggest a dinner&quot; holds no durable fact, so nothing is written.</li></ol><h4>Look at the data yourself</h4><p>Open mongosh (or the Atlas Data Explorer) and inspect both layers:</p><pre>use agent_memory<br><br>// Long-term: one small document per fact<br>db.long_term_memory.find({}, { embedding: 0 }).pretty()<br>// Short-term: many checkpoint documents per thread<br>db.checkpoints.countDocuments({ thread_id: &quot;thread-mon&quot; })</pre><p>The contrast teaches a lot. The long-term collection holds a couple of tidy, human-readable facts. The checkpoints collection holds several snapshots per thread, one after each node, which is why it grows much faster. The collection names above assume the defaults used in this tutorial; if yours differ, run show collections first. (If fromConnString placed the store in a different database, check the store&#39;s options in your package version.)</p><h3>Taking it to production</h3><p>The demo works; production needs four more things: trustworthy identity, privacy controls, clean-up of old data and a guard on memory quality.</p><h4>1. Never trust userId from the request body</h4><p>The namespace is your only wall between users. If your Express route does userId: req.body.userId, anyone can read anyone&#39;s memories by changing one field. Always take userId from your verified auth session or JWT, on the server side. In a multi-tenant SaaS, add the tenant to the namespace too:</p><pre>export const memoryNamespace = (orgId: string, userId: string) =&gt;<br>  [orgId, userId, &quot;memories&quot;];</pre><h4>2. Decide what must never be stored</h4><p>The extractor will save whatever looks durable, and that can include sensitive data: health details, card numbers, passwords pasted by mistake. Add explicit exclusions to the extraction prompt (“never store credentials, payment data, or medical details”), and back them with a code check on f.text before put. If you work in a regulated domain such as healthcare or finance, involve your compliance team before storing anything about end users.</p><h4>3. Give users a real “forget me”</h4><p>Privacy laws and plain decency both require deleting a user’s memories on request. Because everything for a user sits under one namespace, deletion is short:</p><pre>export async function forgetUser(userId: string) {<br>  const ns = memoryNamespace(userId);<br>  let batch = await store.search(ns, { limit: 100 });<br>  while (batch.length) {<br>    await Promise.all(batch.map((item) =&gt; store.delete(item.namespace, item.key)));<br>    batch = await store.search(ns, { limit: 100 });<br>  }<br>}</pre><p>The loop pages through 100 items at a time, so it works for users with thousands of memories without loading them all into RAM. Remember the user’s old threads in the checkpoints collection, too; they contain the raw messages.</p><h4>4. Clean up short-term data</h4><p>Checkpoints grow with every node of every turn, while most threads are never reopened after a week. Pick a retention period (say 30 days) and remove older threads with a scheduled job. Newer checkpointer versions expose a deleteThread(threadId) method; if yours does not, delete by thread_id directly in both checkpoint collections. Long-term memories are different: keep them until the user deletes them or they are superseded.</p><h4>5. Keep memory quality high</h4><p>Bad memories are worse than no memories, because the agent states them confidently. Four habits help:</p><ul><li><strong>Stable keys</strong> (diet, cooking_days) so updates overwrite instead of piling up contradictions.</li><li><strong>Provenance:</strong> store { text, category, sourceThread, savedAt }, so you can explain and audit where a fact came from.</li><li><strong>Let the user see and edit memories.</strong> A simple “Here’s what I remember about you” page builds trust and fixes errors.</li><li><strong>Evaluate the extractor.</strong> Keep 30 to 50 sample messages with the facts you expect, and re-run them whenever you change the prompt or model.</li></ul><h4>6. Watch the indexes</h4><p>Atlas Vector Search indexes are built asynchronously. Right after creating one, searches may return nothing for a minute; this is normal. In the Atlas UI under <strong>Search &amp; Vector Search</strong>, confirm the index status is active before load testing. If you filter searches by fields such as category, make sure those fields are declared as filter fields in the vector index, or the filter will be rejected.</p><h3>Common mistakes and how to spot them</h3><p>Most first-time bugs come from six mistakes, and each has a recognisable symptom.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*UhPKEYEpV7yqfIhYgEPWkA.png" /></figure><h3>Wrapping up</h3><p>You now have an agent with two kinds of memory, both stored in MongoDB:</p><ul><li><strong>Short-term memory</strong> via MongoDBSaver keeps each conversation coherent, saved automatically after every graph step and keyed by thread_id.</li><li><strong>Long-term memory</strong> via MongoDBStore keeps curated facts about each user, organised by namespace and key, shared across every thread.</li><li><strong>A three-node graph</strong> (recall → respond → remember) is the pattern that connects them, and <strong>semantic search</strong> keeps recall relevant as memories grow.</li></ul><p>The most important lesson is not an API call. The checkpointer saves everything automatically, but the store saves only what you choose. The quality of your agent’s memory is the quality of your remember node.</p><h3>Where to go next</h3><ul><li>Let the model decide when to save by turning remember into a tool (save_memory) it can call, instead of running extraction on every turn.</li><li>Split memories into types: <em>semantic</em> (facts), <em>episodic</em> (summaries of past sessions) and <em>procedural</em> (rules the agent learned about how to behave), each in its own namespace.</li><li>Add a nightly job that merges duplicate or conflicting memories with a second LLM pass.</li></ul><p>If you build on this, I would love to hear what you store and how you decide. Drop a comment with your own memory design.</p><h3>Sources</h3><ul><li><a href="https://www.mongodb.com/docs/atlas/ai-integrations/langgraph-js/long-term-memory-store/">Add Long-Term Memory to LangGraph.js Agents with MongoDB Atlas</a>, MongoDB Docs</li><li><a href="https://docs.langchain.com/oss/javascript/integrations/memory/mongodb-long-term-memory.md">Long-term memory with MongoDB Atlas (JavaScript)</a>, LangChain Docs</li><li><a href="https://reference.langchain.com/javascript/langchain-langgraph-checkpoint-mongodb/IndexConfig">IndexConfig reference for @langchain/langgraph-checkpoint-mongodb</a>, LangChain Reference</li><li><a href="https://www.mongodb.com/products/updates/mongodb-support-for-langgraph-js-long-term-memory/">MongoDB Support for LangGraph.js Long-Term Memory</a>, MongoDB product update, May 8, 2026</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8177c4fce7e8" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why Your Kubernetes GPUs Sit at 5% Utilization (and How MIG/Time-Slicing Fixes It)]]></title>
            <link>https://medium.com/@vasanthank29/why-your-kubernetes-gpus-sit-at-5-utilization-and-how-mig-time-slicing-fixes-it-c9ad5b323f24?source=rss-125e2c6438de------2</link>
            <guid isPermaLink="false">https://medium.com/p/c9ad5b323f24</guid>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[nvidia]]></category>
            <category><![CDATA[cloud-computing]]></category>
            <category><![CDATA[mlops]]></category>
            <category><![CDATA[gpu]]></category>
            <dc:creator><![CDATA[Vasanthan K]]></dc:creator>
            <pubDate>Wed, 14 Oct 2026 14:01:04 GMT</pubDate>
            <atom:updated>2026-10-14T14:01:04.766Z</atom:updated>
            <content:encoded><![CDATA[<h4>The most expensive thing in your cluster is doing nothing</h4><p>You finally got GPUs into your Kubernetes cluster. The data science team is happy, the inference service is live, and the cloud bill shows a shiny new line item. Then someone opens a Grafana dashboard and asks a very awkward question: <em>why is this GPU at 5% utilization?</em></p><p>This is not a rare edge case. It is the default outcome for most teams running GPUs on Kubernetes. A single data-center GPU can cost anywhere from a few hundred to several thousand dollars per month in the cloud, and in many clusters most of that capacity sits idle while new pods wait in Pending because &quot;there are no GPUs available.&quot;</p><p>The GPUs are not broken. The workloads are not broken either. The problem is a mismatch between <strong>how Kubernetes hands out GPUs</strong> (one whole card per request) and <strong>how most real workloads use them</strong> (in short bursts, with a fraction of the memory).</p><p>In this article we will walk through the whole story, step by step, assuming you are new to GPUs on Kubernetes:</p><ol><li>How Kubernetes actually sees and allocates a GPU.</li><li>Why that model leads to 5% utilization.</li><li>How to measure the problem properly, so you are not guessing.</li><li><strong>Time-slicing</strong>: letting several pods take turns on one GPU.</li><li><strong>MIG (Multi-Instance GPU)</strong>: physically carving one GPU into isolated smaller GPUs.</li><li>How to choose between them, and what to watch out for in production.</li></ol><p>Every code example comes with an explanation of <em>what</em> it does and <em>why</em> it works, so you can adapt it instead of copy-pasting blindly. All diagrams in this post were created specifically for it.</p><h3>Part 1: How Kubernetes actually sees a GPU</h3><p>Kubernetes was built around two resources it understands natively: <strong>CPU</strong> and <strong>memory</strong>. Both are <em>divisible</em>. You can ask for 250m of CPU (a quarter of a core) or 512Mi of memory, and the scheduler happily packs many small pods onto one node.</p><p>A GPU is different. Kubernetes has no idea what a GPU is. It only learns that GPUs exist through a component called a <strong>device plugin</strong>.</p><h4>What a device plugin does</h4><p>A device plugin is a small program (usually a DaemonSet, so one copy runs on every GPU node) that talks to the kubelet over a gRPC API. For NVIDIA hardware this is the <a href="https://github.com/NVIDIA/k8s-device-plugin">NVIDIA k8s-device-plugin</a>, which is also installed for you by the <a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/index.html">NVIDIA GPU Operator</a>. It does three jobs:</p><ol><li><strong>Discover</strong> the GPUs on the node (using the NVIDIA driver).</li><li><strong>Advertise</strong> them to the kubelet as an <em>extended resource</em> named nvidia.com/gpu, with a count. A node with 4 GPUs reports nvidia.com/gpu: 4.</li><li><strong>Allocate</strong> a specific GPU when a pod is scheduled, by telling the container runtime which device (GPU UUID) to mount into the container.</li></ol><p>You can see the advertised count on any GPU node:</p><pre>kubectl get nodes -o custom-columns=NAME:.metadata.name,GPUS:.status.allocatable.nvidia\.com/gpu</pre><p><strong>What this command does:</strong> kubectl get nodes lists nodes, and -o custom-columns prints only the fields we care about. The path .status.allocatable.nvidia\.com/gpu reads the number of GPUs the node can hand out. The backslash escapes the dot inside nvidia.com, because otherwise kubectl would treat nvidia and com/gpu as two nested keys.</p><h4>How a pod asks for a GPU</h4><p>Here is the simplest possible GPU pod:</p><pre>apiVersion: v1<br>kind: Pod<br>metadata:<br>  name: cuda-test<br>spec:<br>  restartPolicy: Never<br>  containers:<br>    - name: cuda<br>      image: nvcr.io/nvidia/cuda:12.4.1-base-ubuntu22.04<br>      command: [&quot;nvidia-smi&quot;]<br>      resources:<br>        limits:<br>          nvidia.com/gpu: 1</pre><p><strong>Line by line:</strong></p><ul><li>image: nvcr.io/nvidia/cuda:... is an official CUDA base image. It contains the CUDA user-space libraries but not the driver; the driver lives on the host and is injected by the NVIDIA Container Toolkit.</li><li>command: [&quot;nvidia-smi&quot;] just prints the GPU the container can see, then exits. Perfect for a smoke test.</li><li>resources.limits.nvidia.com/gpu: 1 is the important line. It tells the scheduler: &quot;this pod needs one unit of the nvidia.com/gpu resource.&quot;</li></ul><p>For extended resources like GPUs, you only set limits. Kubernetes automatically treats the request as equal to the limit, and you cannot overcommit them.</p><h4>The catch: GPUs are integers</h4><p>With CPU you could write cpu: 0.25. With GPUs you <strong>cannot</strong> write nvidia.com/gpu: 0.25. Extended resources must be whole numbers. So the smallest thing a pod can ask for is one entire GPU, and once it gets it, that GPU is <strong>exclusively</strong> bound to that pod until the pod dies. No other pod can be scheduled onto it, even if the first pod only ever uses 2% of its compute and 1 GB of its 80 GB of memory.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wQ57QnST0VwAyqPxnWWmDg.png" /></figure><p><em>The default flow. The pod requests </em><em>nvidia.com/gpu: 1, the scheduler finds a node with a free GPU, and the device plugin hands that whole card to the pod. With four lightly used pods holding four GPUs, a fifth pod stays Pending even though the node is almost idle.</em></p><p>That single design decision, whole-GPU allocation, is the root cause of almost every “why is my GPU at 5%” conversation.</p><h3>Part 2: Why your GPU sits at 5%</h3><p>Modern data-center GPUs are enormous. An NVIDIA A100 has 108 streaming multiprocessors (SMs) and 40 or 80 GB of memory; an H100 has even more. They were designed for workloads like training a large model, where thousands of matrix multiplications run back to back for hours.</p><p>Most workloads in a typical company cluster look nothing like that. Here are the usual suspects.</p><h4>1. Online inference services</h4><p>An inference API (say, a small text classifier or an embedding model behind a REST endpoint) only uses the GPU <strong>while a request is being processed</strong>. A forward pass might take 15 ms. If the service gets 5 requests per second, the GPU is busy for about 75 ms out of every 1,000 ms. That is roughly 7.5% utilization at best, and real traffic is far spikier than that: busy at 10 a.m., silent at 3 a.m.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*m42A4QA0ZhXaLZSU4CIhjg.png" /></figure><p><em>An illustrative utilization trace of one inference pod that owns a whole GPU. Short bursts reach 60 to 90%, but the average over ten minutes is around 5%.</em></p><h4>2. Jupyter notebooks and dev pods</h4><p>A data scientist launches a notebook with a GPU attached, runs one cell that trains for 4 minutes, then spends 40 minutes reading results, writing code, and attending a meeting. The GPU is reserved the entire time. Multiply that by a team of 10, and you have 10 GPUs that are idle 90% of the day.</p><h4>3. Small models on big cards</h4><p>A 300 MB BERT-style model needs maybe 2 GB of GPU memory to serve. Placing it on an 80 GB H100 wastes 78 GB, and its small kernels cannot fill 132 SMs either. The model is not slow; the card is simply far bigger than the job.</p><h4>4. CPU-bound pipelines</h4><p>Many “GPU” jobs spend most of their time <em>not</em> on the GPU: decoding images, tokenizing text, reading from object storage, waiting on a database. The GPU waits for data, finishes its part in milliseconds, and waits again.</p><h4>5. Batch jobs and CI tests</h4><p>Nightly evaluation jobs and CI pipelines that “just check the model loads on a GPU” each need a GPU for a few minutes. Because allocation is exclusive, they queue behind each other or force you to buy more cards.</p><h4>The real cost</h4><p>Put all of this together and you get the classic pattern: <strong>allocation is 100%</strong> (every GPU is claimed by some pod, new pods are Pending), but <strong>utilization is 5 to 20%</strong>. The fix is not more GPUs. The fix is letting several workloads share one GPU safely. That is exactly what time-slicing and MIG do, in two very different ways.</p><h3>Part 3: Measure before you fix</h3><p>Before changing anything, prove the problem with numbers. You need two views: a quick spot check and a long-running metric you can graph.</p><h4>Quick check with nvidia-smi</h4><p>You can run nvidia-smi inside any pod that has a GPU:</p><pre>kubectl exec -it &lt;your-gpu-pod&gt; -- nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv -l 2</pre><p><strong>What each part means:</strong></p><ul><li>kubectl exec -it &lt;pod&gt; -- runs a command inside the running container.</li><li>--query-gpu=... asks for specific fields instead of the big human-readable table.</li><li>utilization.gpu is the percentage of time in the last sample period during which <strong>at least one kernel</strong> was running on the GPU.</li><li>memory.used / memory.total shows how much GPU memory is actually allocated.</li><li>--format=csv -l 2 prints CSV and repeats every 2 seconds, so you can watch it for a while.</li></ul><p>An important subtlety for beginners: utilization.gpu measures <strong>time</strong>, not <strong>space</strong>. If one tiny kernel uses only 1 of 108 SMs but runs continuously, nvidia-smi will still report close to 100%. So a low number is a reliable sign of waste, but a high number does not prove the GPU is fully used. We will use better metrics below.</p><h4>Long-term monitoring with DCGM Exporter</h4><p>For real decisions you want weeks of history in Prometheus. NVIDIA’s <a href="https://github.com/NVIDIA/dcgm-exporter">DCGM Exporter</a> runs as a DaemonSet, reads metrics from the Data Center GPU Manager (DCGM), and exposes them on a /metrics endpoint that Prometheus scrapes. If you install the GPU Operator, it is enabled by default.</p><p>The metrics that matter most:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/802/1*DLdsZ4zRuaCMXw36BiQXbw.png" /></figure><h4>PromQL queries you can paste into Grafana</h4><p>Average utilization per GPU over the last 7 days:</p><pre>avg by (Hostname, gpu) (<br>  avg_over_time(DCGM_FI_DEV_GPU_UTIL[7d])<br>)</pre><p><strong>How it works:</strong> avg_over_time(...[7d]) takes every sample of the metric in the last 7 days and averages it, per time series. Each series is one GPU, so the result is &quot;average utilization per GPU.&quot; The outer avg by (Hostname, gpu) collapses any extra labels (like pod name) so you get exactly one number per physical GPU.</p><p>Find GPUs that are allocated to a pod but nearly idle:</p><pre>avg_over_time(DCGM_FI_DEV_GPU_UTIL{pod!=&quot;&quot;}[24h]) &lt; 10</pre><p><strong>How it works:</strong> DCGM Exporter attaches namespace, pod and container labels when the GPU is in use by a pod. pod!=&quot;&quot; keeps only GPUs that are assigned. The &lt; 10 filter returns only those that averaged below 10% over the last day. This list is your sharing candidates.</p><p>Memory headroom per GPU, as a percentage:</p><pre>100 * DCGM_FI_DEV_FB_USED / (DCGM_FI_DEV_FB_USED + DCGM_FI_DEV_FB_FREE)</pre><p><strong>Why this matters:</strong> memory decides <em>how many</em> workloads can share a GPU. If a pod uses 4 GB on a 40 GB card, you can fit several neighbors. If it uses 35 GB, sharing will not help no matter how idle the compute is.</p><p>A good rule of thumb: if a GPU averages <strong>under 30% utilization</strong> and <strong>under 50% memory</strong>, it is a strong candidate for sharing.</p><h3>Part 4: Fix #1 — Time-slicing</h3><h4>The idea in plain English</h4><p>Time-slicing is the GPU equivalent of how your laptop’s CPU runs a browser, an editor and Spotify at the same time. There is one physical processor, and the system rapidly switches between programs, giving each a turn.</p><p>With time-slicing enabled, the NVIDIA device plugin <strong>lies to Kubernetes on purpose</strong>. Instead of advertising nvidia.com/gpu: 1 for one physical GPU, it advertises, say, nvidia.com/gpu: 4. Kubernetes now believes the node has 4 GPUs and schedules 4 pods there. All 4 pods get the <em>same</em> physical GPU, and the GPU&#39;s hardware scheduler interleaves their work in time slices.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iwAHRTHYlQ3L8TnhZ668LA.png" /></figure><p><em>Compute is shared by taking turns. Memory is not divided at all: all pods allocate from the same pool, and nothing stops one pod from taking more than its share.</em></p><h4>Why it fixes the 5% problem</h4><p>Remember the inference pod from Figure 2: busy for a few milliseconds, then idle. When four such pods share one card, the idle gaps of one pod get filled by the bursts of another. If each used about 5% on its own, together they use roughly 20%, on one GPU instead of four. You have just freed three GPUs.</p><h4>Step 1: Install the GPU Operator</h4><p>The easiest way to manage everything (driver, container toolkit, device plugin, DCGM exporter) is the NVIDIA GPU Operator, installed with Helm:</p><pre>helm repo add nvidia https://helm.ngc.nvidia.com/nvidia<br>helm repo update<br><br>helm install gpu-operator nvidia/gpu-operator \<br>  --namespace gpu-operator --create-namespace \<br>  --wait</pre><p><strong>What happens here:</strong> helm repo add registers NVIDIA&#39;s chart repository. helm install deploys the operator into a new gpu-operator namespace. The operator then watches for GPU nodes and installs the full NVIDIA software stack on each of them automatically. --wait makes Helm block until all resources are ready, so you know when it is safe to continue. (On managed services like EKS, GKE or AKS, the driver may already be installed; check your provider&#39;s docs and set --set driver.enabled=false if so.)</p><h4>Step 2: Describe the sharing policy in a ConfigMap</h4><pre>apiVersion: v1<br>kind: ConfigMap<br>metadata:<br>  name: time-slicing-config<br>  namespace: gpu-operator<br>data:<br>  any: |-<br>    version: v1<br>    flags:<br>      migStrategy: none<br>    sharing:<br>      timeSlicing:<br>        renameByDefault: false<br>        failRequestsGreaterThanOne: true<br>        resources:<br>          - name: nvidia.com/gpu<br>            replicas: 4</pre><p><strong>Explaining every field:</strong></p><ul><li>data.any is the <em>name of a configuration profile</em>. A single ConfigMap can hold several profiles (for example t4-x4 and a100-x2), and you pick which one each node uses. Here we create one profile called any.</li><li>version: v1 is the schema version of the device plugin config file.</li><li>flags.migStrategy: none tells the plugin that MIG is not in use on these GPUs.</li><li>sharing.timeSlicing.resources is the list of resources to oversubscribe. We take nvidia.com/gpu and set replicas: 4, meaning <em>each physical GPU is advertised as 4 schedulable units</em>. A node with 2 GPUs will now report nvidia.com/gpu: 8.</li><li>renameByDefault: false keeps the resource name as nvidia.com/gpu, so existing pod specs work unchanged. If you set it to true, the shared resource is advertised as nvidia.com/gpu.shared instead. That is useful when you want pods to <em>explicitly opt in</em> to sharing, so nobody lands on a shared GPU by accident.</li><li>failRequestsGreaterThanOne: true rejects pods that ask for more than one replica. This prevents a common misunderstanding: requesting nvidia.com/gpu: 2 on a time-sliced GPU does <strong>not</strong> give you twice the compute, it just gives you two turns on the same card. Failing loudly is better than silently giving someone less than they think.</li></ul><p>Apply it:</p><pre>kubectl apply -f time-slicing-config.yaml</pre><h4>Step 3: Point the device plugin at the config</h4><p>The GPU Operator is configured through a custom resource called ClusterPolicy. We patch it so the device plugin loads our ConfigMap and uses the any profile by default:</p><pre>kubectl patch clusterpolicies.nvidia.com/cluster-policy \<br>  -n gpu-operator --type merge \<br>  -p &#39;{&quot;spec&quot;: {&quot;devicePlugin&quot;: {&quot;config&quot;: {&quot;name&quot;: &quot;time-slicing-config&quot;, &quot;default&quot;: &quot;any&quot;}}}}&#39;</pre><p><strong>What this does:</strong> --type merge merges the JSON into the existing object instead of replacing it. config.name is the ConfigMap to read, and config.default is the profile applied to every node that does not say otherwise. The operator restarts the device plugin pods, which re-register with the kubelet using the new replica count.</p><p>If you want different behavior on different nodes (for example, share the T4 dev nodes but not the H100 training nodes), label individual nodes with the profile name instead of relying on the default:</p><pre>kubectl label node gpu-dev-node-1 nvidia.com/device-plugin.config=any --overwrite</pre><h4>Step 4: Verify</h4><pre>kubectl get node gpu-dev-node-1 -o jsonpath=&#39;{.status.allocatable.nvidia\.com/gpu}&#39;<br># Output: 4   (for a node with 1 physical GPU)<br><br>kubectl get node gpu-dev-node-1 --show-labels | tr &#39;,&#39; &#39;\n&#39; | grep nvidia.com/gpu<br># nvidia.com/gpu.product=Tesla-T4-SHARED<br># nvidia.com/gpu.replicas=4</pre><p>The node now reports 4 allocatable GPUs. The operator also updates node labels: gpu.replicas=4, and the product name gets a -SHARED suffix so you can target (or avoid) shared nodes with a nodeSelector.</p><h4>Step 5: Run four pods on one GPU</h4><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: embedder<br>spec:<br>  replicas: 4<br>  selector:<br>    matchLabels:<br>      app: embedder<br>  template:<br>    metadata:<br>      labels:<br>        app: embedder<br>    spec:<br>      nodeSelector:<br>        nvidia.com/gpu.replicas: &quot;4&quot;<br>      containers:<br>        - name: model<br>          image: my-registry/embedder:1.0<br>          resources:<br>            limits:<br>              nvidia.com/gpu: 1<br>          env:<br>            - name: PYTORCH_CUDA_ALLOC_CONF<br>              value: &quot;expandable_segments:True&quot;</pre><p><strong>Why it is written this way:</strong></p><ul><li>replicas: 4 with nvidia.com/gpu: 1 each consumes exactly the 4 advertised slices. On a single-GPU node, all four pods land on the same physical card.</li><li>nodeSelector on nvidia.com/gpu.replicas: &quot;4&quot; makes sure this Deployment only goes to nodes where sharing is configured, never onto an exclusive training GPU.</li><li>The PYTORCH_CUDA_ALLOC_CONF setting reduces memory fragmentation in PyTorch&#39;s caching allocator. On a shared GPU, wasted memory hurts every neighbor, so small memory hygiene settings matter more than usual.</li></ul><p>If you kubectl exec into each pod and run nvidia-smi, all four will show the <strong>same GPU UUID</strong>. That is time-slicing working.</p><h4>Time-slicing caveats (read these before production)</h4><ol><li><strong>No memory isolation.</strong> All pods share one memory pool. If one pod loads a bigger model or leaks memory, the <em>others</em> hit CUDA out of memory. Each application should cap its own usage, for example torch.cuda.set_per_process_memory_fraction(0.25) in PyTorch, or the memory-fraction options most inference servers provide.</li><li><strong>No fault isolation.</strong> A serious GPU error (an Xid fault) caused by one pod can affect every process on that GPU.</li><li><strong>No performance guarantee.</strong> If all four pods get traffic at the same moment, each gets roughly a quarter of the GPU, plus some context-switching overhead. Latency becomes unpredictable under load.</li><li><strong>The replica count is not a quota.</strong> Kubernetes thinks each pod has “one GPU.” Nothing enforces that a pod uses only a quarter. It is a scheduling hint, not a resource limit.</li></ol><p>Time-slicing is perfect for dev notebooks, CI jobs, demos, and low-traffic internal inference, where everyone on the GPU belongs to the same team. When you need real walls between workloads, you need MIG.</p><h3>Part 5: Fix #2 — MIG (Multi-Instance GPU)</h3><h4>The idea in plain English</h4><p>If time-slicing is roommates sharing one kitchen in shifts, MIG is converting a house into separate apartments, each with its own kitchen, walls and front door.</p><p>MIG is a <strong>hardware feature</strong> introduced with NVIDIA’s Ampere generation. It physically partitions one GPU into up to <strong>7 independent GPU instances</strong>. Each instance gets:</p><ul><li>its own dedicated <strong>streaming multiprocessors</strong> (compute),</li><li>its own dedicated slice of <strong>GPU memory</strong>,</li><li>its own share of <strong>L2 cache and memory bandwidth</strong>,</li><li>its own <strong>fault domain</strong>: an error in one instance does not crash the others.</li></ul><p>To the software running inside, a MIG instance looks and behaves like a small, standalone GPU. To Kubernetes, each instance is a separate schedulable resource.</p><h3>Which GPUs support MIG?</h3><p>MIG is available on data-center GPUs from Ampere onward: <strong>A100</strong> and <strong>A30</strong> (A30 supports up to 4 instances), <strong>H100</strong>, <strong>H200</strong>, and the <strong>Blackwell</strong> data-center line. Popular inference cards like the <strong>T4</strong>, <strong>L4</strong> and <strong>A10</strong> do <strong>not</strong> support MIG. For those, time-slicing (or MPS, covered later) is your option. Always confirm on NVIDIA’s <a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/">MIG user guide</a> for your exact model.</p><h3>Understanding MIG profiles</h3><p>You cannot cut a GPU into arbitrary sizes. NVIDIA defines fixed <strong>profiles</strong>. On an A100 40GB they look like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/652/1*3xLJ9tx9_ZVVCSlNPRajOw.png" /></figure><p>The naming is easy once you see the pattern: <strong>&lt;compute slices&gt;g.&lt;memory&gt;gb</strong>. On an H100 80GB the same pattern applies with doubled memory, for example 1g.10gb, 3g.40gb and 7g.80gb.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*r8DG6X_tiPF_7NxvkyBEoA.png" /></figure><p><em>Three ways to carve the same A100. Layout A gives seven small, equal instances (great for many small inference models). Layout B mixes sizes for teams with different needs. Layout C gives two medium instances, and one slice is left unused because the hardware placement rules do not allow a third 3g instance.</em></p><h4>Step 1: Choose a MIG strategy in the GPU Operator</h4><p>The device plugin can expose MIG instances to Kubernetes in two ways:</p><ul><li><strong>single</strong>: every GPU on the node is split into the <strong>same</strong> profile, and instances are advertised with the familiar name nvidia.com/gpu. Existing pod specs keep working, but pods cannot choose a size.</li><li><strong>mixed</strong>: each profile gets its own resource name, like nvidia.com/mig-1g.5gb and nvidia.com/mig-3g.20gb. Pods request exactly the size they need. This is what most teams want.</li></ul><pre>helm upgrade --install gpu-operator nvidia/gpu-operator \<br>  -n gpu-operator --create-namespace \<br>  --set mig.strategy=mixed \<br>  --wait</pre><h4>Step 2: Tell the node how to partition itself</h4><p>The GPU Operator includes a <strong>MIG Manager</strong> that watches a node label and reconfigures the GPUs to match. You never have to run low-level commands by hand:</p><pre>kubectl label node gpu-a100-node-1 nvidia.com/mig.config=all-1g.5gb --overwrite</pre><p><strong>What happens behind the scenes:</strong></p><ol><li>MIG Manager sees the label change.</li><li>It <strong>evicts GPU pods</strong> from the node (the GPU must be idle to repartition).</li><li>It enables MIG mode on each GPU and creates seven 1g.5gb instances.</li><li>It restarts the device plugin, which now advertises nvidia.com/mig-1g.5gb: 7 per GPU.</li><li>It sets the label nvidia.com/mig.config.state=success when done.</li></ol><p>Because step 2 evicts workloads, <strong>treat a MIG reconfiguration like a node maintenance event</strong>: cordon and drain first in production.</p><p>Other built-in layouts include all-2g.10gb, all-3g.20gb, and all-balanced (a mix of sizes). You can also define your own layouts in a custom ConfigMap for MIG Manager, for example two 1g.5gb plus one 2g.10gb plus one 3g.20gb.</p><p>Watch the reconfiguration complete and check the new resources:</p><pre>kubectl get node gpu-a100-node-1 -o jsonpath=&#39;{.metadata.labels.nvidia\.com/mig\.config\.state}&#39;<br># success<br><br>kubectl get node gpu-a100-node-1 -o json | jq &#39;.status.allocatable | with_entries(select(.key | startswith(&quot;nvidia.com&quot;)))&#39;<br># {<br>#   &quot;nvidia.com/mig-1g.5gb&quot;: &quot;7&quot;<br># }</pre><p><strong>What the second command does:</strong> it fetches the node as JSON, and jq filters the allocatable map down to only keys starting with nvidia.com. Instead of nvidia.com/gpu: 1, you now see seven schedulable MIG instances.</p><h4>What MIG Manager does under the hood (for the curious)</h4><p>If you SSH into a GPU node, you can do the same thing manually with nvidia-smi. Understanding these commands helps when you are debugging:</p><pre># 1. Enable MIG mode on GPU 0 (requires the GPU to be idle; may need a reset)<br>sudo nvidia-smi -i 0 -mig 1<br><br># 2. List the profiles this GPU supports, with their numeric IDs<br>sudo nvidia-smi mig -lgip<br><br># 3. Create three 1g.5gb GPU instances (profile ID 19 on A100 40GB)<br>#    and a default compute instance inside each (-C)<br>sudo nvidia-smi mig -cgi 19,19,19 -C<br><br># 4. List the GPU instances you created<br>sudo nvidia-smi mig -lgi</pre><p><strong>Key concepts in these commands:</strong></p><ul><li>A <strong>GPU instance (GI)</strong> is the hardware partition: memory slice, cache and SM group.</li><li>A <strong>compute instance (CI)</strong> lives inside a GI and is what processes actually run on. The -C flag creates one default CI per GI, which is what you want 99% of the time.</li><li>Profile IDs (like 19) differ between GPU models, which is why -lgip exists. Always list first, then create.</li></ul><p>In Kubernetes, prefer the label-based MIG Manager over manual commands. Manual changes are not tracked declaratively and can be overwritten.</p><h4>Step 3: Request a MIG slice from a pod</h4><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: fraud-scorer<br>spec:<br>  replicas: 7<br>  selector:<br>    matchLabels:<br>      app: fraud-scorer<br>  template:<br>    metadata:<br>      labels:<br>        app: fraud-scorer<br>    spec:<br>      containers:<br>        - name: model<br>          image: my-registry/fraud-scorer:2.3<br>          resources:<br>            limits:<br>              nvidia.com/mig-1g.5gb: 1</pre><p><strong>How to read this:</strong> the only difference from a normal GPU pod is the resource name. nvidia.com/mig-1g.5gb: 1 means &quot;give me one 1g.5gb instance.&quot; With replicas: 7, one A100 now serves seven fully isolated copies of the model, each with a guaranteed 5 GB of memory and 1/7 of the compute. Inside each container, nvidia-smi -L shows a device with a MIG- prefixed UUID.</p><h3>Why MIG isolation matters</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*LFBWBzcZ-W0DX1XIRpXzQw.png" /></figure><p><em>With time-slicing, one misbehaving pod can exhaust memory for everyone. With MIG, each pod is boxed into its own memory and compute, so a failure stays contained.</em></p><h4>MIG caveats</h4><ol><li><strong>Fixed sizes.</strong> You only get the predefined profiles. If your model needs 7 GB, a 1g.5gb slice is too small and you jump to 2g.10gb, wasting 3 GB.</li><li><strong>Repartitioning is disruptive.</strong> Changing layouts evicts pods from the GPU. Plan layouts per node pool rather than changing them often.</li><li><strong>Compute does not “burst.”</strong> A 1g instance can never borrow idle SMs from a neighbor. For very spiky workloads, that can be <em>less</em> efficient than time-slicing.</li><li><strong>Hardware cost.</strong> Only higher-end data-center GPUs support it.</li></ol><h4>Combining MIG with time-slicing</h4><p>You can stack the two techniques: partition an A100 with MIG, then time-slice each MIG instance. For example, 7 1g.5gb instances with replicas: 2 gives you 14 schedulable units. Teams use this for dev environments: hard isolation between teams (MIG), soft sharing within a team (time-slicing). In the time-slicing ConfigMap, you list the MIG resource name (such as nvidia.com/mig-1g.5gb) instead of nvidia.com/gpu, and set migStrategy: mixed.</p><h3>Part 6: Time-slicing vs MIG — how to choose</h3><p>Neither technique is “better.” They solve the same problem with different trade-offs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*IaL61J_5kmHY87Ep80BCvQ.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*McVdZT2-sZ3vMybVngo__Q.png" /></figure><p><em>A practical decision path. Start with “is the GPU actually busy?” because the cheapest optimization is the one you do not need.</em></p><h4>A third option worth knowing: MPS</h4><p>NVIDIA’s <strong>Multi-Process Service (MPS)</strong> sits between the two. Like time-slicing, it works in software and on most GPUs. Unlike time-slicing, it lets kernels from different processes run <strong>at the same time</strong> (concurrently) rather than taking turns, and it can cap each client’s memory and share of SMs. Recent versions of the NVIDIA device plugin support MPS as a sharing mode, configured almost exactly like the time-slicing ConfigMap (sharing.mps instead of sharing.timeSlicing). It still lacks full fault isolation, so treat it as &quot;better time-slicing for trusted workloads&quot; rather than a MIG replacement.</p><h4>And on the horizon: Dynamic Resource Allocation (DRA)</h4><p>The integer nvidia.com/gpu model is a decade-old Kubernetes limitation. The newer <strong>Dynamic Resource Allocation (DRA)</strong> API lets drivers describe devices richly (memory size, MIG profile, sharing mode) and lets pods ask for them with structured claims instead of a single number. NVIDIA maintains a DRA driver for GPUs. If you are designing a new platform, it is worth tracking, but time-slicing and MIG through the device plugin remain the most widely deployed approach today.</p><h3>Part 7: Production checklist and common pitfalls</h3><h4>A rollout plan that works</h4><ol><li><strong>Measure for at least a week</strong> with DCGM Exporter. Identify GPUs under 30% utilization and under 50% memory.</li><li><strong>Split node pools by purpose.</strong> For example: gpu-train (exclusive, no sharing), gpu-infer (MIG), gpu-dev (time-slicing). Use node labels and taints so workloads land in the right pool.</li><li><strong>Start with time-slicing on dev nodes.</strong> It is low-risk, reversible by editing a ConfigMap, and gives the fastest visible win.</li><li><strong>Move production inference to MIG</strong> where your hardware supports it and you need predictable latency.</li><li><strong>Re-measure.</strong> Compare utilization, pod Pending time, and GPU count before and after.</li></ol><h4>Taint shared nodes so only opted-in pods land there</h4><pre>kubectl taint nodes gpu-dev-node-1 gpu-sharing=time-sliced:NoSchedule</pre><p>Pods that are happy to share add a matching toleration:</p><pre>tolerations:<br>  - key: &quot;gpu-sharing&quot;<br>    operator: &quot;Equal&quot;<br>    value: &quot;time-sliced&quot;<br>    effect: &quot;NoSchedule&quot;</pre><p><strong>Why this matters:</strong> a taint is a “keep out” sign on a node. Only pods with a matching toleration can be scheduled there. This prevents a latency-sensitive production service from accidentally being placed on a crowded, time-sliced GPU.</p><h4>Cap GPU memory inside your application (time-slicing)</h4><p>Since time-slicing does not enforce memory limits, each workload should limit itself. In PyTorch:</p><pre>import os<br>import torch<br><br># Fraction of the GPU this process may use, e.g. 0.25 for 4 replicas.<br>fraction = float(os.getenv(&quot;GPU_MEMORY_FRACTION&quot;, &quot;0.25&quot;))<br>torch.cuda.set_per_process_memory_fraction(fraction, device=0)<br>model = torch.load(&quot;model.pt&quot;, map_location=&quot;cuda:0&quot;)</pre><p><strong>What this does:</strong> set_per_process_memory_fraction tells PyTorch&#39;s memory allocator never to grab more than that fraction of total GPU memory. If the code tries, it raises an out-of-memory error <em>in this pod only</em>, instead of silently starving its neighbors. Reading the value from an environment variable means you can tune it per Deployment without rebuilding the image. TensorFlow, vLLM and Triton Inference Server have similar settings.</p><h4>Common pitfalls</h4><ul><li><strong>Expecting </strong><strong>nvidia.com/gpu: 2 to mean &quot;more GPU&quot; on a time-sliced node.</strong> It does not. Use failRequestsGreaterThanOne: true to catch this early.</li><li><strong>Changing MIG layouts on a busy node.</strong> MIG Manager evicts GPU pods. Drain first.</li><li><strong>Mixing training and inference on shared GPUs.</strong> One training job can saturate a GPU and wreck inference latency for everyone else. Keep training on exclusive GPUs.</li><li><strong>Trusting </strong><strong>GPU-Util alone.</strong> It measures time, not how much of the chip is used. Look at DCGM_FI_PROF_SM_ACTIVE and memory too.</li><li><strong>Forgetting about quotas.</strong> With 4x time-slicing, a namespace quota of requests.nvidia.com/gpu: 4 now means one physical GPU, not four. Revisit your ResourceQuotas after enabling sharing.</li></ul><h3>Conclusion</h3><p>Your GPUs sit at 5% utilization not because something is broken, but because Kubernetes, by default, gives every pod an entire GPU, and most workloads need only a sliver of one, for a sliver of the time.</p><ul><li><strong>Time-slicing</strong> fixes this in minutes on almost any NVIDIA GPU by letting pods take turns. It is ideal for trusted, bursty workloads, but offers no memory or fault isolation.</li><li><strong>MIG</strong> fixes it with real hardware partitions on A100, H100 and newer, giving each pod guaranteed memory and compute. It is ideal for multi-tenant production inference, at the cost of fixed sizes and disruptive reconfiguration.</li><li>You can <strong>combine both</strong>, and you should <strong>measure before and after</strong> with DCGM Exporter.</li></ul><p>Start small: pick your idlest dev GPU node, enable time-slicing with 4 replicas, and watch four pods share a card that used to serve one. The results in your Grafana dashboard, and in your cloud bill, tend to make the case for the rest of the rollout on their own.</p><h3>Sources</h3><ul><li><a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/index.html">NVIDIA GPU Operator documentation</a></li><li><a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/22.9.1/gpu-sharing.html">Time-Slicing GPUs in Kubernetes (NVIDIA GPU Operator docs)</a></li><li><a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/">NVIDIA MIG User Guide</a></li><li><a href="https://github.com/NVIDIA/k8s-device-plugin">NVIDIA k8s-device-plugin</a></li><li><a href="https://github.com/NVIDIA/dcgm-exporter">NVIDIA DCGM Exporter</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c9ad5b323f24" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>