{
    "version": "https://jsonfeed.org/version/1",
    "title": "Vivari engineering",
    "home_page_url": "https://vivari.run/blog/",
    "description": "Teardowns of the browser-side Node runtime behind Vivari.",
    "items": [
        {
            "id": "https://vivari.run/blog/the-memory-budget-of-a-tab",
            "content_html": "<p>A tab running <code>nuxt dev</code> was using 3.46 GB. That is not a number you optimise\nyour way out of with a hunch, and the hunch everybody has is the same one:\nbundlers are memory hogs, so it must be the bundler.</p>\n<p>It was not the bundler, whose entire contribution was 22.5 MB. It was three\nother things, and they are not even measured in the same units: a filesystem\nholding 929 MB of <code>node_modules</code> as bytes, a persistence layer writing 4.7 GB to\nstore 53 MB, and an editor spending 621 MB parsing one set of type definitions\ntwice.</p>\n<p>This post is the autopsy rather than a list of tips, because the useful part is\nwhich assumptions turned out to be wrong.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"where-the-3-gb-actually-was\">Where the 3 GB actually was<a href=\"https://vivari.run/blog/the-memory-budget-of-a-tab#where-the-3-gb-actually-was\" class=\"hash-link\" aria-label=\"Direct link to Where the 3 GB actually was\" title=\"Direct link to Where the 3 GB actually was\" translate=\"no\">​</a></h2>\n<p>The tab had already come down to 3.09 GB when it was measured properly. The\nsplit, on one machine running one project:</p>\n<table><thead><tr><th>what</th><th>resident</th></tr></thead><tbody><tr><td>PID 8, the <code>nuxt dev</code> process worker</td><td>~1.87 GB</td></tr><tr><td>the filesystem worker</td><td>~580 MB</td></tr><tr><td>eight other small process workers</td><td>~175 MB total</td></tr></tbody></table>\n<p>Inside that 1.87 GB dev server, the in-process esbuild Go heap was <strong>22.5 MB</strong>.</p>\n<p>That single measurement killed the plan everyone arrives with. Isolating\nesbuild, tearing it down between builds, moving it to its own worker: all of it\nwould have saved approximately nothing, and all of it would have been weeks. The\nGo heap is 1.2% of the process it lives in.</p>\n<p>Meanwhile the filesystem worker, which holds nothing but bytes, was the second\nlargest thing in the tab and the largest one we could do anything about. The\n1.87 GB above it is Nuxt's and Vite's heap, allocated by their code for their\nreasons. The 580 MB is ours. That is the finding.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"finding-one-node_modules-is-the-largest-addressable-object-in-the-program\">Finding one: node_modules is the largest addressable object in the program<a href=\"https://vivari.run/blog/the-memory-budget-of-a-tab#finding-one-node_modules-is-the-largest-addressable-object-in-the-program\" class=\"hash-link\" aria-label=\"Direct link to Finding one: node_modules is the largest addressable object in the program\" title=\"Direct link to Finding one: node_modules is the largest addressable object in the program\" translate=\"no\">​</a></h2>\n<p>Every file in this runtime lives in a Rust virtual filesystem compiled to Wasm,\nwhich means every byte of <code>node_modules</code> is sitting in one linear memory. A\nmid-sized project's dependency tree is a few hundred megabytes of text that is\nread once at startup and then almost never touched again.</p>\n<p>Text compresses. So cold file contents are zlib-compressed in place, behind a\ngate of two constants:</p>\n<div class=\"language-rust codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-rust codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">const</span><span class=\"token plain\"> </span><span class=\"token constant\" style=\"color:rgb(189, 147, 249)\">MIN_COMPRESS_BYTES</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">usize</span><span class=\"token plain\"> </span><span class=\"token operator\">=</span><span class=\"token plain\"> </span><span class=\"token number\">4096</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">;</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\"></span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">const</span><span class=\"token plain\"> </span><span class=\"token constant\" style=\"color:rgb(189, 147, 249)\">MIN_COMPRESS_RATIO</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">f64</span><span class=\"token plain\"> </span><span class=\"token operator\">=</span><span class=\"token plain\"> </span><span class=\"token number\">0.95</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">;</span><br></div></code></pre></div></div>\n<p>Below 4096 bytes, do not bother: zlib's framing and the bookkeeping cost more\nthan the win, and <code>node_modules</code> is full of eleven byte <code>index.js</code> files. Above\na 0.95 ratio, do not bother either: the file is already compressed, storing it\npacked saves nothing measurable and every future read pays an inflate.</p>\n<p>Measured on Nuxt, in Chrome: VFS content went from <strong>929.0 MB to 273.6 MB</strong>, a\n29% ratio and 655 MB saved, and the whole Chrome tab dropped from <strong>2.9 GB to\n2.1 GB</strong>.</p>\n<p>It is on by default in the SDK, and only an explicit <code>compress: false</code> turns it\noff. The Rust struct itself defaults to compression off, which looks like a\ncontradiction and is deliberate: it keeps the flag A/B testable from a\nbenchmark without touching the shipped default.</p>\n<p>The gate is four lines of arithmetic, and it is the reason the number above is\n655 MB rather than something embarrassing. Here it is running against three\nkinds of file, using the same deflate from the same Rust crate the filesystem\ncompresses with:</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">The gate the filesystem runs on every file</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=vfs-compression\" title=\"The gate the filesystem runs on every file\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:540px\"></iframe></div>\n<p>The third block is the one to read twice. A megabyte of random bytes deflates to\n1,048,752 bytes, which is 176 bytes <strong>larger</strong> than what went in, and the gate\ncorrectly stores it raw. That is the whole case for the ratio test: without it,\nevery <code>.tgz</code>, <code>.png</code> and <code>.wasm</code> in a dependency tree would be stored slightly\nbigger than it arrived and would pay an inflate on every read for the privilege.</p>\n<p>One honest note about that demo, because it matters. The virtual filesystem does\nnot tell guest code what it decided about a file, so the script <strong>recomputes</strong>\nthe gate in front of you rather than reading its verdict. It is the same test on\nthe same bytes with the same compressor, and it is not instrumentation.</p>\n<p>The edit to make is <code>SIZE</code>. Drop it to 2048 and every sample is stored raw,\nincluding the one that deflates to a thousandth of its size, because it failed\nthe size test and the size test is the one the VFS checks first. The script\nstill prints a ratio for each, since it computes both tests rather than\nshort-circuiting the way the Rust does, and seeing <code>beats 0.95 true</code> sit next to\n<code>the VFS keeps it RAW</code> is the clearest possible statement of what the first\nconstant is for.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"finding-two-persisting-53-mb-cost-47-gb-of-writes\">Finding two: persisting 53 MB cost 4.7 GB of writes<a href=\"https://vivari.run/blog/the-memory-budget-of-a-tab#finding-two-persisting-53-mb-cost-47-gb-of-writes\" class=\"hash-link\" aria-label=\"Direct link to Finding two: persisting 53 MB cost 4.7 GB of writes\" title=\"Direct link to Finding two: persisting 53 MB cost 4.7 GB of writes\" translate=\"no\">​</a></h2>\n<p>A session survives a page reload by mirroring the filesystem into OPFS, the\nbrowser's origin-private file storage. The steady state is small: about 53 MB\nfor a real project. Getting there was costing <strong>4.7 GB of writes</strong>.</p>\n<p>Three offenders, and none of them is a large file:</p>\n<ul>\n<li class=\"\"><strong>npm's own cache temp directory.</strong> One <code>_cacache/tmp/&lt;uuid&gt;</code> cost 1,461 MB\nacross 76 writes, and npm deletes it moments later. We were faithfully\npersisting a scratch directory so that it could be faithfully persisted again\nas it changed, and then removed.</li>\n<li class=\"\"><strong>npm's debug log.</strong> <code>_logs/*-debug-0.log</code> cost 607 MB across 3,799 writes,\nbecause a log file is appended a line at a time and a mirror that does not\nunderstand appends rewrites the file each time.</li>\n<li class=\"\"><strong>The manifest.</strong> The index of which paths exist was rewritten 12,847 times,\ntotalling roughly 2.1 GB, to describe about 3,000 paths. Every write touched\nthe whole thing.</li>\n</ul>\n<p>Total after fixing all three: about 144 MB, for the same 53 MB of durable state.</p>\n<p>The general shape here is worth naming, because it is not specific to browsers.\nWrite amplification is invisible in every profiler you would normally reach for:\nmemory looked fine, the filesystem looked fine, and the only symptom was that\ninstalls felt slower than the network could explain. You have to go and count\nthe writes.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"finding-three-the-ide-was-paying-twice-for-one-thing\">Finding three: the IDE was paying twice for one thing<a href=\"https://vivari.run/blog/the-memory-budget-of-a-tab#finding-three-the-ide-was-paying-twice-for-one-thing\" class=\"hash-link\" aria-label=\"Direct link to Finding three: the IDE was paying twice for one thing\" title=\"Direct link to Finding three: the IDE was paying twice for one thing\" translate=\"no\">​</a></h2>\n<p>Monaco runs a <strong>separate full language service for each of its <code>typescript</code> and\n<code>javascript</code> modes</strong>. Each one parses the whole dependency <code>.d.ts</code> payload into\nroughly 310 MB, so a project with both kinds of file naively pays about 621 MB,\nmeasured, for two services doing identical work over identical inputs.</p>\n<p>Mapping <code>.js</code> files to the <code>typescript</code> mode halves it. TypeScript's language\nservice handles JavaScript perfectly well; it is the same compiler.</p>\n<p>That is a configuration change, and it is in this post rather than a footnote\nbecause 310 MB is larger than most of the things people spend a week optimising,\nand it was found by reading a memory profile rather than by reasoning about the\ncode.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-emphatically-not-the-bottleneck\">What is emphatically not the bottleneck<a href=\"https://vivari.run/blog/the-memory-budget-of-a-tab#what-is-emphatically-not-the-bottleneck\" class=\"hash-link\" aria-label=\"Direct link to What is emphatically not the bottleneck\" title=\"Direct link to What is emphatically not the bottleneck\" translate=\"no\">​</a></h2>\n<p>Two things that look like they should be slow, measured so nobody has to guess:</p>\n<p><strong>Filesystem write throughput.</strong> Writing a 12,000 file tree, the first 1,000\nfiles cost 6.9 microseconds each and files 11,000 to 12,000 cost 3.6\nmicroseconds each. The whole tree lands in about 44 milliseconds. Install time\nlives in the network, in tar extraction, in npm's own JavaScript, and in the\nOPFS mirror. It does not live in the virtual filesystem.</p>\n<p><strong>Registry metadata, sort of.</strong> A full install pulled 421 MB of packuments\nwithout an <code>.npmrc</code>, and 108 MB with one that restricts the fields requested.\nThe registry gzips them about tenfold, so the wire cost is around 45 MB while\nthe cost of holding and parsing them is the full 421 MB. That is a case where\nthe network number and the memory number differ by an order of magnitude and\nonly one of them is the problem.</p>\n<p>There is one more measurement in this family, from shipping a prebuilt\n<code>node_modules</code> snapshot for a template. Cold origin, Chrome, Starlight: fetch\n0.4s, restore 4.0s for 13,459 entries, dev server listening at 31.8s, and OPFS\nholding 112 MB instead of 246 MB. The asset is 111.4 MB raw and 26.0 MB gzipped,\nand its buffer is transferred rather than copied between workers. The\ninteresting row is the restore: 4.0s in the browser against 0.1s headless, and\nthe entire difference is the OPFS mirror. Again the storage layer, not the\ncompute.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"how-it-fails-which-is-not-what-you-would-expect\">How it fails, which is not what you would expect<a href=\"https://vivari.run/blog/the-memory-budget-of-a-tab#how-it-fails-which-is-not-what-you-would-expect\" class=\"hash-link\" aria-label=\"Direct link to How it fails, which is not what you would expect\" title=\"Direct link to How it fails, which is not what you would expect\" translate=\"no\">​</a></h2>\n<p>A Node process that runs out of memory throws, prints a heap trace, and dies\nalone. Under a memory ceiling, a browser tab does not do that.</p>\n<p>Running the tab in a container with a roughly 1.6 GB ceiling, the kernel\nSIGKILLs the <strong>renderer</strong>, with error code 9 and a cgroup failure count\nclimbing. Not one worker: the whole tab. What the user sees is Chrome's crash\npage, not a frozen terminal and not an error in the console, and nothing in the\nruntime gets a chance to report anything.</p>\n<p>That changes what the memory work is for. It is not about being tidy. Past the\nceiling there is no graceful degradation available, because the process that\nwould have degraded gracefully no longer exists.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-honest-to-claim\">What is honest to claim<a href=\"https://vivari.run/blog/the-memory-budget-of-a-tab#what-is-honest-to-claim\" class=\"hash-link\" aria-label=\"Direct link to What is honest to claim\" title=\"Direct link to What is honest to claim\" translate=\"no\">​</a></h2>\n<ul>\n<li class=\"\"><strong>Every number here is one machine, one browser, one project.</strong> Nuxt in\nChrome, Starlight in Chrome. They are here to show the shape and the ordering\nof the costs, not to be benchmarks anyone should quote.</li>\n<li class=\"\"><strong>You cannot reproduce these from inside the VM.</strong> <code>process.memoryUsage()</code> in\na guest process returns fixed constants, so a script running in the sandbox\ncannot observe any of this. Every figure above comes from browser task\nmanager and profiler measurements taken outside the runtime, which is also\nwhy the demo above measures the gate rather than the saving.</li>\n<li class=\"\"><strong>Compression is a tradeoff and the ratio test is where it is made.</strong> A cold\nfile that is read again pays an inflate. The 0.95 constant is a judgement,\narrived at by measurement on one kind of workload, and a workload that reads\nits dependency tree constantly would want a different one.</li>\n<li class=\"\"><strong>The OPFS numbers are about write volume, not about durability.</strong> Nothing\nhere changes what survives a reload; it changes how many bytes it costs to\nkeep it surviving.</li>\n<li class=\"\"><strong>The Monaco figure is a Monaco figure.</strong> It is what two language services\ncost on one dependency payload, and it will move with the size of your\n<code>.d.ts</code> files.</li>\n</ul>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-general-version\">The general version<a href=\"https://vivari.run/blog/the-memory-budget-of-a-tab#the-general-version\" class=\"hash-link\" aria-label=\"Direct link to The general version\" title=\"Direct link to The general version\" translate=\"no\">​</a></h2>\n<p>Three findings, and the thing they have in common is that none of them is about\nthe code that was doing the work.</p>\n<p>The bundler, the compiler, the dev server: those are what a profile looks like\nit should be about, and between them they were a rounding error against a\nfilesystem full of text, a mirror writing gigabytes to store megabytes, and an\neditor holding two copies of the same parse.</p>\n<p>In a browser tab, the interesting resources are the ones the platform makes you\nimplement yourself. On a laptop, <code>node_modules</code> costs page cache that the kernel\nreclaims when it feels like it, and nobody counts it. Here it is a Rust <code>HashMap</code>\nyou allocated, and it is on your bill. Persistence is a mirror you wrote rather\nthan a filesystem the OS provides, so its write amplification is yours too. The\nplatform is not doing you any invisible favours, which is inconvenient and,\noccasionally, clarifying: everything that costs something is something you can\nsee.</p>\n<p>More on the architecture in\n<a href=\"https://vivari.run/docs/how-it-works\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">the how-it-works docs</a>, and the post that\nexplains why the filesystem lives in a Rust module at all is\n<a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">the one about the single blocking primitive</a>.</p>\n<hr>\n<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no\nper-seat fee, self-host every asset. The code is on\n<a href=\"https://github.com/maitrungduc1410/vivari\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">GitHub</a> and the\n<a href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Studio</a> runs in your browser.</p>",
            "url": "https://vivari.run/blog/the-memory-budget-of-a-tab",
            "title": "A Nuxt dev server in a tab cost 3.46 GB, and the biggest thing we could actually shrink was the filesystem",
            "summary": "Three findings from profiling a real project in a browser tab, each of which contradicted the obvious guess. The largest addressable object in the process is node_modules, persisting 53 MB was costing 4.7 GB of writes, and the IDE was paying twice for one language service.",
            "date_modified": "2026-09-06T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Browser platform",
                "WebAssembly"
            ]
        },
        {
            "id": "https://vivari.run/blog/bun-without-bun",
            "content_html": "<p>Bun is a single native binary written in Zig around JavaScriptCore. There is no\n<code>wasm32</code> build of it, there is not going to be one soon, and a browser tab\ncannot execute a Mach-O or ELF file regardless.</p>\n<p>So <code>bun index.ts</code> in a page is not Bun. It is Bun's API, implemented on top of\n<a class=\"\" href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser\">the Node runtime these posts keep describing</a>,\nand the honest version of that sentence is the whole subject here. A\ncompatibility shim is only useful if you can tell, from inside it, which parts\nare real. Most of this post is about the parts that are not, and how they say\nso.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-actually-on-path\">What is actually on PATH<a href=\"https://vivari.run/blog/bun-without-bun#what-is-actually-on-path\" class=\"hash-link\" aria-label=\"Direct link to What is actually on PATH\" title=\"Direct link to What is actually on PATH\" translate=\"no\">​</a></h2>\n<p><code>bun</code> and <code>bunx</code> are ordinary coreutils, installed eagerly rather than lazily\nunpacked on first use, because unlike the\n<a class=\"\" href=\"https://vivari.run/blog/real-package-managers-in-the-browser\">real vendored npm, yarn and pnpm CLIs</a>\nthere is no tarball to unpack: they are purpose-built shims running on the same\nruntime your code runs on. <code>bun run</code>, <code>bun test</code>, <code>bun build</code> and the <code>Bun</code>\nglobal are all implemented here. <code>bun install</code> is not: it delegates to the real\nnpm CLI, because reimplementing a resolver would be a worse lie than borrowing\none. The TypeScript in <code>index.ts</code> is handled by\n<a class=\"\" href=\"https://vivari.run/blog/synchronous-esm\">the synchronous stripper</a> that every other <code>.ts</code> file in the\nruntime goes through, not by Bun's transpiler.</p>\n<p>Most of the surface is uninteresting in the good way. <code>Bun.escapeHTML</code>,\n<code>Bun.deepEquals</code>, <code>Bun.stringWidth</code>, <code>Bun.semver</code>, <code>Bun.Glob</code>, <code>Bun.which</code>,\n<code>Bun.gzipSync</code> are small pure functions with a specification and a test. There\nare two places where that stopped being true.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-one-module-that-could-not-be-shimmed\">The one module that could not be shimmed<a href=\"https://vivari.run/blog/bun-without-bun#the-one-module-that-could-not-be-shimmed\" class=\"hash-link\" aria-label=\"Direct link to The one module that could not be shimmed\" title=\"Direct link to The one module that could not be shimmed\" translate=\"no\">​</a></h2>\n<p>Every other Bun API had something underneath it to delegate to. <code>bun:sqlite</code> did\nnot: there is no SQLite in the Node runtime to borrow, and the API is\n<strong>synchronous</strong> by design. <code>db.query(sql).all()</code> returns rows, not a promise.\nThere is nowhere to await an engine booting.</p>\n<p>The engine is the official <code>@sqlite.org/sqlite-wasm</code> build, the same C source\nSQLite's own test suite covers, compiled by the SQLite authors. It is\n<strong>committed</strong> to the repository at 844 KiB alongside a manifest recording the\nupstream version and SHA-256, rather than fetched at build time, because both\nspike tiers need it on a bare checkout and a spike that skips when its artifact\nis missing looks green while proving nothing. The refresh script validates the\nbinary it pulls: magic bytes, required exports, that its imports are a subset of\nwhat the loader supplies, and that its declared memory minimum still fits. An\nupstream build that changed its ABI fails the refresh rather than failing at\nsomebody's first query.</p>\n<p><strong>The Emscripten glue is not used.</strong> The package ships 578 KB of it, and it is\nuseless here twice over: it is async-init, meaning it fetches and calls\n<code>WebAssembly.instantiate</code>, and it routes file I/O through MEMFS or NODEFS,\nneither of which exists in this environment. So the loader supplies the module's\n36 imports itself and instantiates with a bare <code>new WebAssembly.Module(bytes)</code>\nfollowed by <code>new WebAssembly.Instance(...)</code>. Both are synchronous, which is\nillegal on a main thread and legal on a worker, which is where all guest code\nruns. It is the same trick\n<a class=\"\" href=\"https://vivari.run/blog/databases-and-the-http-parser\">the llhttp binding uses</a>.</p>\n<p>The memory is created on this side, 128 pages initial and 2 GiB maximum,\nunshared, because the build imports memory rather than exporting it. Growth goes\nthrough <code>emscripten_resize_heap</code>, and every cached typed-array view has to be\nre-derived whenever <code>memory.buffer</code> identity changes, since growth detaches the\nold <code>ArrayBuffer</code>. That is the sort of detail the glue would normally handle and\nthe reason people use the glue.</p>\n<p><strong>And then the part that makes it worth doing.</strong> A <code>sqlite3_vfs</code> is registered\nwhose <code>xOpen</code>, <code>xRead</code>, <code>xWrite</code>, <code>xTruncate</code>, <code>xFileSize</code>, <code>xDelete</code>, <code>xAccess</code>\nand <code>xFullPathname</code> call the runtime's own <code>fs</code>, which is\n<a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">the SharedArrayBuffer syscall bridge</a>. So a <code>.sqlite</code>\nfile is an ordinary file in the virtual filesystem. It shows up in the file tree,\nit outlives the process that made it, and the next process reads it. The reads\nand writes take explicit offsets, which is exactly the <code>pread</code> and <code>pwrite</code> a\nSQLite VFS wants.</p>\n<p>SQLite needs real C function pointers for those callbacks, and\n<code>WebAssembly.Table.prototype.set</code> will not accept a plain JavaScript function.\nSo each callback is wrapped in a hand-assembled 40-byte Wasm module that imports\nthe function and re-exports it, and the export goes into\n<code>__indirect_function_table</code>. Forty bytes of hand-written Wasm per callback is\neither the ugliest thing in this repository or the most satisfying, depending on\nthe day.</p>\n<p>Two semantics are implemented rather than approximated, because approximating\nthem corrupts data. <code>safeIntegers</code> governs reads: <code>true</code> returns exact <code>BigInt</code>s,\n<code>false</code> returns <code>Number</code>s, lossily above 2^53, which is Bun's documented\nbehaviour and the reason the toggle has to exist. Binding is exact either way, so\na <code>bigint</code> argument goes in through <code>sqlite3_bind_int64</code> and one outside int64\nrange throws a <code>RangeError</code> naming the value instead of wrapping it. And\n<code>db.transaction()</code> nests through SAVEPOINT, with nesting decided by\n<code>sqlite3_get_autocommit</code> rather than a counter we keep, so a hand-written\n<code>BEGIN</code> in the middle of things does not desync it.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"two-sentences-and-the-difference-between-them-is-load-bearing\">Two sentences, and the difference between them is load-bearing<a href=\"https://vivari.run/blog/bun-without-bun#two-sentences-and-the-difference-between-them-is-load-bearing\" class=\"hash-link\" aria-label=\"Direct link to Two sentences, and the difference between them is load-bearing\" title=\"Direct link to Two sentences, and the difference between them is load-bearing\" translate=\"no\">​</a></h2>\n<p>Roughly thirty Bun APIs in this shim do nothing but throw. That file is the best\nwriting in the repository, and its argument is this: there are two reasons an\nAPI can fail, and telling someone the wrong one wastes their afternoon.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">\"&lt;api&gt; is not supported in Vivari (browser sandbox): &lt;reason&gt;\"</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">\"&lt;api&gt; is not implemented in the Vivari shim: &lt;reason&gt;\"</span><br></div></code></pre></div></div>\n<p>The first means the capability does not exist in a page. A raw socket,\n<code>dlopen(3)</code>, an OS keychain, engine internals. No amount of shim work changes\nit, and the code has to run somewhere else. The second means it could work here\nand nobody has written it: a gap, not a limit.</p>\n<p>Conflating them is its own kind of dishonesty. \"Not supported\" tells you to stop\nand redesign. \"Not implemented\" tells you to file an issue or send a patch.\nWhere an API is half of each, and a TCP client is exactly that, since it can\nreach another in-VM process forever but can never reach the internet, the\nmessage says both, in that order.</p>\n<p>There is a second rule, and it is the one that keeps projects alive. <strong>The\nsymbol is always exported. The throw is always on the call.</strong> So\n<code>import { dlopen } from \"bun:ffi\"</code> still loads, and a property read during some\ndependency's module-level feature detection still returns a function. A\nload-time throw is strictly worse: one unused import at the top of a transitive\ndependency takes down a project that never touches the API.</p>\n<p>The catalogue covers <code>Bun.listen</code>, <code>Bun.connect</code>, <code>Bun.udpSocket</code>,\n<code>Bun.RedisClient</code>, <code>Bun.sql</code>, <code>Bun.postgres</code>, <code>Bun.Terminal</code>, <code>Bun.WebView</code>,\n<code>Bun.mmap</code>, <code>Bun.peek</code>, <code>Bun.secrets</code>, <code>Bun.dlopen</code>, the zstd helpers,\n<code>Bun.generateHeapSnapshot</code>, <code>Bun.openInEditor</code> and the whole of <code>bun:ffi</code>. The\nnative addon half of the same file is not Bun-specific at all: <code>require(\"bcrypt\")</code>\nfrom plain Node code hits precisely the same wall, and one catalogue of \"impossible\nin a browser, and here is what to do instead\" beats two that drift apart.</p>\n<p>All of that is checkable rather than something to take my word for. The script\nbelow is running Bun's API in this page: a block of one-line results, a visible\npause while argon2id does its 64 MiB of work, real SQLite reporting its own\nversion number and three rows, and then a refusal.</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">Bun in a tab, including the parts that refuse</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=bun-shim\" title=\"Bun in a tab, including the parts that refuse\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:580px\"></iframe></div>\n<p>The block at the bottom is the part to play with. Uncomment any line and run it\nagain, and you get the exact sentence that API throws. The two message shapes\nare visibly different, which is the entire point of there being two.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-ones-that-are-real-and-one-that-was-not\">The ones that are real, and one that was not<a href=\"https://vivari.run/blog/bun-without-bun#the-ones-that-are-real-and-one-that-was-not\" class=\"hash-link\" aria-label=\"Direct link to The ones that are real, and one that was not\" title=\"Direct link to The ones that are real, and one that was not\" translate=\"no\">​</a></h2>\n<p><strong><code>Bun.password</code> is genuine argon2id</strong> at Bun's own documented cost parameters:\n64 MiB of memory, two passes, one lane. Measured in Wasm under Node on one\nmachine, hashing takes about 97ms and verifying takes about the same, and in a\nbrowser tab it is somewhat slower again, which is the pause you just sat through\nin the demo. That number is supposed to be large. A password hash that returns\ninstantly is a password hash that is not doing its job, and the most common way to get\ncompatibility wrong here would have been to substitute something cheaper and\ncall it argon2id.</p>\n<p><strong><code>Bun.hash</code> was wrong and is now pinned.</strong> It started as a bespoke\nmultiply-xor hash that agreed with real Bun on nothing at all, which is a\nperfectly good hash function and a completely useless compatibility shim, since\nthe entire value of <code>Bun.hash</code> is that two systems compute the same number. It\nis now wyhash final v3, checked against published vectors.</p>\n<p><strong><code>Bun.sleepSync</code> used to spin.</strong> Right duration, one core pinned at 100% for\nit. It now parks on <code>Atomics.wait</code>, with the spin left in as a documented\nfallback for a browser main thread, where parking is illegal.</p>\n<p><strong><code>bun:test</code> is stricter than real Bun in one place.</strong>\n<code>expect(settledPromise).rejects.toThrow()</code> returns <code>undefined</code> in real Bun; this\nrunner always returns a real promise, and drains outstanding async assertions\nafter each test body. A snapshot file written here has been read and passed by a\nreal <code>bun test</code>, which is the strongest compatibility evidence in the shim.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-bunbuild-is-not\">What <code>Bun.build</code> is not<a href=\"https://vivari.run/blog/bun-without-bun#what-bunbuild-is-not\" class=\"hash-link\" aria-label=\"Direct link to what-bunbuild-is-not\" title=\"Direct link to what-bunbuild-is-not\" translate=\"no\">​</a></h2>\n<p>It is not esbuild. There is no tree shaking and no minifier, and <code>minify</code>,\n<code>splitting</code>, <code>sourcemap</code>, <code>bytecode</code> and <code>--compile</code> throw rather than silently\nproducing something that does not match the option you asked for. It bundles,\nand that is the extent of the claim.</p>\n<p>Streaming is the other place to be careful. A <code>ReadableStream</code> response body is\nbuffered in full: measured at 25 MB into an unread socket with <code>writableLength</code>\nnever leaving zero. Backpressure never engages. It is left honestly buffered\nrather than half-implemented, which is the right call and is also a real limit\nif you were planning to stream a large file out of <code>Bun.serve</code>.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-honest-to-claim\">What is honest to claim<a href=\"https://vivari.run/blog/bun-without-bun#what-is-honest-to-claim\" class=\"hash-link\" aria-label=\"Direct link to What is honest to claim\" title=\"Direct link to What is honest to claim\" translate=\"no\">​</a></h2>\n<ul>\n<li class=\"\"><strong>This is not Bun and will never be byte-for-byte Bun.</strong> It is Bun's API on\nthe Node runtime, so anything that depends on JavaScriptCore semantics,\nBun's transpiler output, or Bun's process startup is out of scope by\nconstruction.</li>\n<li class=\"\"><strong><code>bun:sqlite</code> has three limits, each a sandbox fact rather than a\nshortcut.</strong> <code>xSync</code> is a no-op because the runtime's <code>fsync</code> is, so the\nrollback journal is still written and replayed and a crash mid-transaction\nrecovers, but power loss is not survivable the way real SQLite promises.\nThere is no file locking, so two processes writing one database can corrupt\nit, and this matches what upstream ships: the official build's default VFS is\nliterally <code>unix-none</code>, SQLite's lock-free one. And <code>journal_mode = WAL</code> needs\nshared memory across processes, so it is declined with a one-time warning and\nSQLite stays in <code>delete</code> mode, which is SQLite's own documented behaviour when\na VFS cannot do WAL. ORMs that set WAL opportunistically therefore keep\nworking.</li>\n<li class=\"\"><strong>The argon2id timing is measured in Wasm under Node on one machine</strong>, and a\ntab is slower again. Treat it as an order of magnitude, not a benchmark.</li>\n<li class=\"\"><strong>The refusal list is a snapshot.</strong> Several entries are \"not implemented\"\nrather than \"not supported\", which is a promise that they could move.</li>\n</ul>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-general-version\">The general version<a href=\"https://vivari.run/blog/bun-without-bun#the-general-version\" class=\"hash-link\" aria-label=\"Direct link to The general version\" title=\"Direct link to The general version\" translate=\"no\">​</a></h2>\n<p>The temptation in a compatibility layer is to make the surface as wide as\npossible, because the surface is what a table on a marketing page measures. Every\nAPI you stub to return a plausible empty value makes that table better and makes\nthe layer worse, because the failure moves from your code to the user's, six\nframes deep, with a message that names neither of you.</p>\n<p>The two sentences at the top of that refusal file are worth more than any of the\nAPIs underneath them. A shim's honesty is a feature with a spec: name the API,\nsay which of the two kinds of failure this is, and say what to do instead. It\ncosts one string per function and it is the difference between a limitation and\na bug report.</p>\n<p>More on the Bun surface in <a href=\"https://vivari.run/docs/bun\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">the Bun docs</a>, and the\nprevious post covers\n<a class=\"\" href=\"https://vivari.run/blog/synchronous-esm\">why every import in this runtime is rewritten to CommonJS at load time</a>.</p>\n<hr>\n<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no\nper-seat fee, self-host every asset. The code is on\n<a href=\"https://github.com/maitrungduc1410/vivari\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">GitHub</a> and the\n<a href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Studio</a> runs in your browser.</p>",
            "url": "https://vivari.run/blog/bun-without-bun",
            "title": "Bun runs in the tab and there is no Bun in it, so the interesting part is what refuses",
            "summary": "Bun is a Zig binary with no wasm32 build, so bun index.ts in a browser is an emulation over the Node runtime. The engineering worth reading about is bun:sqlite driving a bare sqlite3.wasm, and a refusal policy with two message shapes.",
            "date_modified": "2026-08-30T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Runtime",
                "Bun"
            ]
        },
        {
            "id": "https://vivari.run/blog/synchronous-esm",
            "content_html": "<p>Recent Node versions will let you <code>require()</code> an ES module. It is a genuinely\nhard thing to have shipped, and it comes with two documented refusals:\n<code>ERR_REQUIRE_ASYNC_MODULE</code> if anything in the required graph uses top-level\nawait, and <code>ERR_REQUIRE_CYCLE_MODULE</code> if the graph has a cycle that crosses the\nCommonJS boundary.</p>\n<p>Node can refuse, because <code>import()</code> is always sitting there as an escape hatch.\nTell the user to await it and the problem is theirs.</p>\n<p>In a browser worker there is no escape hatch. <code>require()</code> is\n<a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">synchronous all the way down</a> because the filesystem\nunder it is, and a project's entry point is <code>require</code>d by a loader that cannot\nreturn a promise to anyone. Every <code>import</code> becomes CommonJS at load time or the\nprogram does not start. Refusing was not on the menu.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-the-rewrite-actually-is\">What the rewrite actually is<a href=\"https://vivari.run/blog/synchronous-esm#what-the-rewrite-actually-is\" class=\"hash-link\" aria-label=\"Direct link to What the rewrite actually is\" title=\"Direct link to What the rewrite actually is\" translate=\"no\">​</a></h2>\n<p>There is no bundler here and no compile step. Each module is rewritten as it is\nread, by <code>es-module-lexer</code>, into the same synchronous CommonJS everything else\nin the runtime lives in. <code>import</code> becomes a <code>require</code>, <code>export</code> becomes a\nproperty on an exports object, <code>import.meta</code> becomes a small object built from\nthe filename, and dynamic <code>import()</code> becomes a helper that returns an already\nresolved promise.</p>\n<p>Every generated identifier is namespaced: <code>__oc_require</code>, <code>__oc_import</code>,\n<code>__oc_exports</code>, <code>__oc_module</code>. That is not tidiness. User code is allowed to\ndeclare its own <code>require</code> and its own <code>module</code>, and a great deal of published\ncode does.</p>\n<p>The interop helpers are all emitted on <strong>one leading line</strong>, which looks like\nsomebody minifying for no reason. It is so that line numbers in the rewritten\nfile still match the file you have open, which is what makes\n<a class=\"\" href=\"https://vivari.run/blog/a-debugger-with-no-inspector\">breakpoints land on the right line</a>.</p>\n<p>That is the easy part. What follows is four years of other people's module\ngraphs.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"live-bindings-and-the-half-of-them-that-is-not-implemented\">Live bindings, and the half of them that is not implemented<a href=\"https://vivari.run/blog/synchronous-esm#live-bindings-and-the-half-of-them-that-is-not-implemented\" class=\"hash-link\" aria-label=\"Direct link to Live bindings, and the half of them that is not implemented\" title=\"Direct link to Live bindings, and the half of them that is not implemented\" translate=\"no\">​</a></h2>\n<p>An ESM export is a binding, not a value. If a module exports <code>let count</code> and\nincrements it later, an importer that already read it sees the new number. Real\nESM gets that by being lazy at both ends: the exporter exposes a binding rather\nthan a copy, and the importer reads it at the point of use rather than at the\npoint of import.</p>\n<p>This loader is lazy at one end and eager at the other, and the asymmetry is\nworth stating before anything else in this section, because it is the one place\nwhere the rewrite is knowingly not ESM.</p>\n<p><strong>The export side is getters.</strong> A module's own exports are exposed as accessors\non the exports object, which is how esbuild and rollup model the same thing.</p>\n<p><strong>The import side is a snapshot.</strong> A used <code>import { X } from './m'</code> compiles to\n<code>const X = __oc_m['X']</code>: one read, at the top of the importing module, and that\nvalue is what the rest of the body sees. Increment <code>X</code> in the source module\nafterwards and the importer will not notice.</p>\n<p>Most code never sees the difference, because most imported names are functions,\nand a function is the same object before and after. It shows up on a mutable\n<code>let</code>, and it shows up hard in a cycle, which is the rest of this section.</p>\n<p>The load-bearing detail on the export side is not the getters. It is that they\nare emitted <strong>before</strong> the import requires.</p>\n<p>Consider a cycle, which npm is full of. Module A imports B, B imports A back. B\nruns while A's body is still on the stack. If A emitted its export getters after\nits own imports, B reads <code>undefined</code> from A, because A has not got to that line\nyet. yargs is the canonical case in the wild: <code>command.js</code> imports\n<code>isYargsInstance</code> from <code>yargs-factory.js</code>, which imports <code>command.js</code> right\nback. Put the getters first and an exported function, which is hoisted anyway,\nis reachable through its getter before A's body has run at all.</p>\n<p>The failure mode when this is wrong is what makes it expensive. You do not get\n\"circular import detected\". You get Astro's middleware reporting\n<code>Function.prototype.apply was called on undefined</code>, four frames from anything\nyou wrote.</p>\n<p>A second, subtler version of the same bug: a barrel file that imports a name and\nthen re-exports it. Astro's <code>render/index.js</code> does\n<code>import { Fragment } from './common.js'</code> and then <code>export { Fragment }</code>. When\n<code>common.js</code> is mid-cycle, the eager <code>const Fragment = common.Fragment</code> snapshot\nhits a <code>const</code> that is still in its temporal dead zone, and you get\n<code>Cannot access 'Fragment' before initialization</code>. So a re-export is compiled to\na lazy live binding to the source module rather than a read of the snapshot,\nwhich defers the read until the cycle has settled, exactly as\n<code>export { X } from 'm'</code> already did.</p>\n<p>And the getters have to close over their own key. <code>export *</code> copies names in a\nloop, and a getter that closes over the shared loop variable resolves every name\nto the last key of the object. Vue's <code>index.mjs</code> re-exports everything, so\n<code>createApp</code> quietly became <code>withScopeId</code>, and Nuxt's server rendering then read\n<code>.config</code> off the wrong object. Per-iteration closure, one line, found the\nexpensive way.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-fallback-that-buys-back-the-other-half\">The fallback that buys back the other half<a href=\"https://vivari.run/blog/synchronous-esm#the-fallback-that-buys-back-the-other-half\" class=\"hash-link\" aria-label=\"Direct link to The fallback that buys back the other half\" title=\"Direct link to The fallback that buys back the other half\" translate=\"no\">​</a></h2>\n<p>That leaves the general case. A cycle whose imported name is a <code>const</code>, a class\nor a singleton has no barrel shape to exploit: the eager <code>const X = __oc_m['X']</code>\nsimply runs too early and throws <code>Cannot access 'X' before initialization</code>. Astro's\nruntime is full of them, <code>apiContextRoutesSymbol</code>, <code>AstroConfigSchema</code>,\n<code>globalContentLayer</code>, <code>telemetry</code>.</p>\n<p>So there is a second compiler. When a module's eager attempt throws a\n<code>ReferenceError</code> whose message matches \"before initialization\" or \"is not\ndefined\", the loader recompiles <strong>that one module</strong> with every import bound as a\ngetter on an <code>__oc_live</code> object, and runs the whole body inside\n<code>with (__oc_live) { ... }</code>. A bare reference to an imported name then resolves\nlazily through the getter, at use, which is what real ESM does, while a local\ndeclaration that shadows the name still wins natively. That is what makes it\nscope-correct without rewriting a single reference, and rewriting references\ncorrectly is the part nobody wants to hand-roll.</p>\n<p>Two reasons it is a fallback rather than the default. <code>with</code> deoptimises the\nwhole body and requires sloppy mode, so a normal module should not pay for it.\nAnd it is safe to re-run only because the eager attempt threw in the prelude,\nbefore the body ran: the retry re-defines configurable export getters and\nre-runs already cached requires, so there are no double side effects.</p>\n<p>Here is a module graph doing all of this, written into the virtual filesystem by\nthe script itself and then imported. The line to look at is the first\n<code>reporter:</code> line, which prints <code>count = 0</code> from inside the cycle while\n<code>counter.mjs</code> is still evaluating.</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">A module graph, rewritten to CommonJS at load time</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=esm-live-bindings\" title=\"A module graph, rewritten to CommonJS at load time\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:560px\"></iframe></div>\n<p><strong>The last <code>reporter:</code> line is the whole section, and it is worth separating\nfrom the line under it.</strong> That third <code>reporter:</code> line is <code>reporter.mjs</code> printing\nits own bare named import, <code>count</code>, after two <code>bump()</code> calls, and it says <code>2</code>.\nThat looks like a live binding and is not one. It is the fallback:\n<code>reporter.mjs</code> reads <code>count</code> while <code>counter.mjs</code>'s <code>let count</code> is still in its\ntemporal dead zone, that throws, and the recompile is what made the read lazy.</p>\n<p>The line below it, <code>read from the namespace</code>, also says <code>2</code> and proves nothing\nof the sort. That one is <code>counter.count</code>, a property access on the namespace\nobject, which goes through the export getter every time it is evaluated. It is\nlive with a cycle and without one, because the export side was never the\nproblem.</p>\n<p>Take the cycle away and the two lines stop agreeing. A plain non-cyclic module\nthat does <code>import { count, bump }</code>, calls <code>bump()</code> twice and then logs <code>count</code>,\nprints <code>0</code> here and <code>2</code> under Node, because nothing threw, so nothing was\nrecompiled. Read the same value off a namespace instead and you get <code>2</code> either\nway. Nothing warns you which of the two you wrote. That is the sharpest edge in\nthis loader and it is the reason the honest list at the end of this post has the\nbullet it has.</p>\n<p>Every module body in the demo is a string you can edit. The other instructive\nchange is to delete the <code>__esModule</code> line from <code>legacy.cjs</code> and run it again,\nfor the reason in the next section.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"__esmodule-is-not-proof-of-a-default-export\"><code>__esModule</code> is not proof of a default export<a href=\"https://vivari.run/blog/synchronous-esm#__esmodule-is-not-proof-of-a-default-export\" class=\"hash-link\" aria-label=\"Direct link to __esmodule-is-not-proof-of-a-default-export\" title=\"Direct link to __esmodule-is-not-proof-of-a-default-export\" translate=\"no\">​</a></h2>\n<p>When you <code>import x from</code> a CommonJS module, what should <code>x</code> be? Node's answer is\nsimple and total: <code>module.exports</code>, always. Babel's answer, which the entire\ntranspiled ecosystem is built on, is that a module carrying the <code>__esModule</code>\nflag was originally ESM, so <code>x</code> should be its <code>.default</code>.</p>\n<p>Both are defensible. The trouble is that <code>tsc --module commonjs</code> stamps\n<code>__esModule</code> on every file it emits, including the ones that only ever assign\nnamed exports. The flag means \"transpiled\", and the unwrap needs \"has a\ndefault\".</p>\n<p><code>@embroider/core</code> is one of those files: flag set, no <code>default</code> key. So\n<code>import core from '@embroider/core'</code> handed <code>@embroider/vite</code> <code>undefined</code>, and\nEmber's config load died at <code>const { cleanUrl } = core</code>. The fix is to require\nthe key to exist as well as the flag, which keeps the Babel unwrap that real\n<code>export default</code> code depends on and falls back to Node's answer otherwise.</p>\n<p>Now the honest part, and it is in the source as a signed confession rather than\nsomething I am volunteering. The dynamic <code>import()</code> helper <strong>deliberately does\nnot</strong> use that narrower test. It still treats <code>__esModule</code> as proof of an ESM\nnamespace, so <code>(await import('&lt;tsc-emitted-cjs&gt;')).default</code> is <code>undefined</code> here\nwhere Node gives you <code>module.exports</code>.</p>\n<p>That is not laziness. Narrowing it the same way would break the other direction:\nour own transpiled ESM sets <code>__esModule</code> too, and a module with no\n<code>export default</code> would then get a synthesised <code>ns.default = m</code> that Node never\ngives it. Telling those two cases apart needs a marker that <code>__esModule</code> cannot\ncarry. It is a known divergence, recorded rather than papered over, and it is\nthe one place in the loader where two correct behaviours cannot both be had.</p>\n<p>There is a related constraint pulling the other way. Dynamic <code>import()</code> has to\nresolve to a module <strong>namespace</strong>, not the raw <code>require()</code> value. Returning the\nbare exports left a CommonJS default import with no <code>default</code> key, which almost\nnothing noticed because real code mostly reads it through the static path. Vite's\nserver-side module runner does notice: it asserts <code>'default' in mod</code> for\nexternalised CommonJS dependencies, and threw\n<code>Named export 'default' not found. The requested module 'cssesc' is a CommonJS module</code>\non Astro.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"top-level-await-and-a-compile-error-that-lies\">Top-level await, and a compile error that lies<a href=\"https://vivari.run/blog/synchronous-esm#top-level-await-and-a-compile-error-that-lies\" class=\"hash-link\" aria-label=\"Direct link to Top-level await, and a compile error that lies\" title=\"Direct link to Top-level await, and a compile error that lies\" translate=\"no\">​</a></h2>\n<p>Top-level await is the case Node declines outright, and it is not optional here:\nVite's own binary starts with <code>await import('node:inspector')</code>.</p>\n<p>The wrapper each module is compiled into is a plain, non-async function, so\n<code>new Function</code> rejects the parse. The fix is to recompile the ESM body as an\n<code>AsyncFunction</code>, which makes the module evaluate to a promise that gets threaded\nthrough the entry point so the top-level body can await while the loop pumps.</p>\n<p>Deciding <em>when</em> to do that is where it got interesting, because the parser will\nnot tell you. At the top level of a non-async function, <code>await x</code> parses <code>await</code>\nas an identifier, so the error names the <strong>next</strong> token. You do not get \"await is\nonly valid in async functions\". SvelteKit's <code>core/sync/ts.js</code> does\n<code>ts = (await import('ts')).default</code>, which after the import rewrite is\n<code>await __oc_import('ts')</code>, and the parser's verdict is\n<code>SyntaxError: Unexpected identifier '__oc_import'</code>.</p>\n<p>Sniffing that message reliably is hopeless, so the loader does not try. Any\ncompile failure on an ESM file is retried as an <code>AsyncFunction</code>. Real top-level\nawait then compiles; a genuine syntax error fails again and is reported with the\nfilename appended. The retry is on the error path only, so the happy path pays\nnothing.</p>\n<p><strong>The limit, stated plainly: only the entry module can block on top-level\nawait.</strong> A dependency deep in the graph that uses it is still not supported, and\nthat is why <a class=\"\" href=\"https://vivari.run/blog/databases-and-the-http-parser\">PGlite ships in the templates as its CommonJS build</a>\nrather than its ESM one. Choosing CJS there avoids the problem instead of\ndiscovering it.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"it-is-a-lexer-which-is-fast-and-occasionally-wrong\">It is a lexer, which is fast and occasionally wrong<a href=\"https://vivari.run/blog/synchronous-esm#it-is-a-lexer-which-is-fast-and-occasionally-wrong\" class=\"hash-link\" aria-label=\"Direct link to It is a lexer, which is fast and occasionally wrong\" title=\"Direct link to It is a lexer, which is fast and occasionally wrong\" translate=\"no\">​</a></h2>\n<p><code>es-module-lexer</code> does not build an AST. It skims for the constructs it cares\nabout, which is why this is affordable to do on every module of every install\nrather than once in a build step.</p>\n<p>The cost is that skimming has to be exactly right about where strings, template\nliterals and comments end. A coarse template skip that ignores <code>${}</code>\ninterpolation desyncs on modern bundled code: a regex inside an interpolation in\n<code>@vitest/pretty-format</code> was misread as a string, which swallowed the matching\nbrace and lost every top-level <code>export</code> after it. The module then compiled with\nno exports and the importer got an empty object.</p>\n<p>So the skimmer descends into interpolations properly. It is still a skimmer, and\nthat is the trade being made: a parse of every file would be correct and would\ncost more than the rest of module loading put together.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-typescript-in-front-of-it\">The TypeScript in front of it<a href=\"https://vivari.run/blog/synchronous-esm#the-typescript-in-front-of-it\" class=\"hash-link\" aria-label=\"Direct link to The TypeScript in front of it\" title=\"Direct link to The TypeScript in front of it\" translate=\"no\">​</a></h2>\n<p>Node's loader is not the only thing that has to be synchronous. A <code>.ts</code> file has\nto become JavaScript before the ESM rewrite sees it, with no <code>tsc</code>, no esbuild,\nand no await, which means a dependency-free token rewriter whose output has to\nparse.</p>\n<p>The hard problem in a type stripper is <code>&lt;</code>. Deciding whether it opens a generic\nor is a less-than comparison needs the previous token: an identifier, a closing\nparenthesis or a closing angle bracket means a generic at a declaration or call\nsite. A generic <strong>arrow</strong> function is a separate case, because it begins an\nexpression rather than a declaration.</p>\n<p>Three of its bugs made it into a release, and their symptoms are a nice ladder.\nThe type skipper counted braces only at depth zero, so <code>Array&lt;{ detail: string }&gt;</code>\nleft <code>}&gt;;</code> behind as live code, which at least fails loudly at load. <code>as</code> and\n<code>satisfies</code> were treated as cast keywords after any token, so\n<code>Bun.semver.satisfies(...)</code> was eaten as a type assertion: the call vanished, the\nimporter got <code>undefined</code>, and the process exited zero. A stripper bug that\nthrows is a bug. A stripper bug that succeeds is a support ticket six months\nlater.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-honest-to-claim\">What is honest to claim<a href=\"https://vivari.run/blog/synchronous-esm#what-is-honest-to-claim\" class=\"hash-link\" aria-label=\"Direct link to What is honest to claim\" title=\"Direct link to What is honest to claim\" translate=\"no\">​</a></h2>\n<ul>\n<li class=\"\"><strong>This is a rewrite, not an ESM implementation.</strong> There is no module map, no\nlinking phase, no <code>import.meta.resolve</code> against a real registry of module\nrecords. The semantics that survive are the ones that can be modelled in\nCommonJS plus getters, which turns out to be most of them, and it is not all\nof them.</li>\n<li class=\"\"><strong>Named imports are eager snapshots on the default path.</strong> Import-side\nliveness exists, and it is a recompile that only fires when the eager read\nthrows. So a module that imports a mutable <code>let</code> from a module with no cycle,\nand expects to see later writes to it, reads the value it saw at import time\nand is given no warning. Two smaller consequences of the fallback itself:\nassigning to an imported binding inside it is a silent no-op where real ESM\nthrows, and an import used at top-level initialisation inside a cycle still\ncannot be satisfied, because the source genuinely is not ready.</li>\n<li class=\"\"><strong>Top-level await works in the entry module only.</strong></li>\n<li class=\"\"><strong><code>(await import(x)).default</code> differs from Node for a <code>tsc</code>-emitted CommonJS\nmodule</strong>, deliberately, for the reason above.</li>\n<li class=\"\"><strong>The scanner can be wrong on pathological source.</strong> Every case found so far\nis fixed and the fix is tested, which is not the same as a proof.</li>\n<li class=\"\"><strong><code>export * from</code> a module that later mutates its own exports object</strong> is\noutside what getters copied at load time can model.</li>\n</ul>\n<p>Every one of the bugs in this post came from running a real project rather than\na test suite, which is a statement about coverage as much as about the bugs.\nYargs, Vue, Astro, Ember, SvelteKit, Vitest and Vite each found something no\nfixture had.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-general-version\">The general version<a href=\"https://vivari.run/blog/synchronous-esm#the-general-version\" class=\"hash-link\" aria-label=\"Direct link to The general version\" title=\"Direct link to The general version\" translate=\"no\">​</a></h2>\n<p>The interesting thing about this subsystem is that it exists because of a\nconstraint one layer down. Nothing about ESM demanded a lexer and a getter\nprotocol; the synchronous filesystem did, and the filesystem is synchronous\nbecause Node's <code>require()</code> is, and <code>require()</code> is synchronous because 2009.</p>\n<p>Node gets to draw a line and say <code>ERR_REQUIRE_ASYNC_MODULE</code>. That line is a\nluxury of having somewhere else to send people. Take it away and you find out\nwhich parts of the module system are semantics and which parts were always just\nscheduling.</p>\n<p>Mostly it is scheduling. The two exceptions, real top-level await in a\ndependency and the <code>__esModule</code> ambiguity, are documented above rather than\nhidden, because a loader that is quietly wrong about a module's shape is the\nworst possible thing to have underneath a package manager.</p>\n<p>More on the runtime in <a href=\"https://vivari.run/docs/how-it-works\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">the architecture docs</a>,\nand the post that explains why any of this has to be synchronous is\n<a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">the one about the single blocking primitive</a>.</p>\n<hr>\n<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no\nper-seat fee, self-host every asset. The code is on\n<a href=\"https://github.com/maitrungduc1410/vivari\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">GitHub</a> and the\n<a href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Studio</a> runs in your browser.</p>",
            "url": "https://vivari.run/blog/synchronous-esm",
            "title": "Node can require() an ES module now, and it refuses two things. We could not refuse either",
            "summary": "ESM is asynchronous by design and require() is synchronous by contract. Node resolves that by declining the hard cases. In a browser worker there is no escape hatch, so every import is rewritten to CommonJS by a lexer at load time, and this is what that costs.",
            "date_modified": "2026-08-23T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Runtime",
                "Node.js"
            ]
        },
        {
            "id": "https://vivari.run/blog/a-debugger-with-no-inspector",
            "content_html": "<p>A breakpoint is not a feature you write. It is a favour the engine does you.\nWhen you set one in VS Code, nothing in your program changes: V8's inspector\nholds the isolate, walks the real call stack, and hands back scopes it already\nhad. Every step debugger you have used is a thin client in front of that.</p>\n<p>A Web Worker has no inspector. There is no <code>--inspect</code> port to open, no\n<code>inspector</code> binding to require, no way to ask the engine to stop. So the first\nquestion here was not how to build a debug UI. It was where a pause could\npossibly come from.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-pause-has-to-come-from-the-program-itself\">The pause has to come from the program itself<a href=\"https://vivari.run/blog/a-debugger-with-no-inspector#the-pause-has-to-come-from-the-program-itself\" class=\"hash-link\" aria-label=\"Direct link to The pause has to come from the program itself\" title=\"Direct link to The pause has to come from the program itself\" translate=\"no\">​</a></h2>\n<p>If the engine will not stop your code, your code has to stop itself. That means\nthe source that runs is not the source you wrote.</p>\n<p><code>acorn</code> parses the guest's own file and weaves in probes: <code>__vvdbg.line</code> before\neach statement, <code>__vvdbg.brk</code> for a literal <code>debugger;</code>, <code>__vvdbg.push</code> and\n<code>__vvdbg.pop</code> around calls so there is a shadow call stack to report. Each\nlexical block also gets a <code>__vv_ev</code> eval closure, which is the unglamorous piece\nthat makes Variables and <code>evaluateOnCallFrame</code> show the block you are actually\nstanding in rather than the function's outermost scope.</p>\n<p>Where this happens in the pipeline is the part that took thought. The\ninstrumentation runs <strong>after</strong> the TypeScript and JSX strip, so acorn is looking\nat plain ES rather than syntax it does not know, and <strong>before</strong>\n<a class=\"\" href=\"https://vivari.run/blog/synchronous-esm\">the ESM to CommonJS rewrite</a>, so line numbers still match the\nfile the reader has open in the editor. One layer earlier and the parse fails on\na type annotation. One layer later and every breakpoint lands on the wrong line.</p>\n<p>There is a bailout, and it matters more than it looks. If acorn throws for any\nreason, the module self-heals to the original source and runs uninstrumented.\nYou lose breakpoints in that one file. You do not lose the program.</p>\n<p>The whole thing lives in a lazy <code>import()</code> chunk of roughly 195 KB, fetched only\nwhen a debug buffer is present. A normal run never parses a byte of it.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-second-sharedarraybuffer\">The second SharedArrayBuffer<a href=\"https://vivari.run/blog/a-debugger-with-no-inspector#the-second-sharedarraybuffer\" class=\"hash-link\" aria-label=\"Direct link to The second SharedArrayBuffer\" title=\"Direct link to The second SharedArrayBuffer\" translate=\"no\">​</a></h2>\n<p><a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">Everything else in the runtime</a> rides one 1 MiB\n<code>SharedArrayBuffer</code> per process: write a request, <code>Atomics.wait</code>, get notified,\nread the response. The debugger cannot use it, and the reason is a nice one.</p>\n<p>A process parked at a breakpoint is not sitting in the syscall loop. It is\nparked in the middle of a <code>__vvdbg.line</code> probe, halfway down the user's own call\nstack. The syscall buffer is a channel for a client that is asking questions. A\npaused process is answering them.</p>\n<p>So a debug target gets a second, independent buffer, allocated only when\n<code>VV_DEBUG</code> is set:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">[ control: Int32 STATE, Int32 LEN ][ data region: JSON CDP command bytes ]</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">STATE values: DBG_STATE_EMPTY = 0, DBG_STATE_CMD = 1</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">LEN:          byte length of the command JSON</span><br></div></code></pre></div></div>\n<p>The paused worker blocks on <code>Atomics.wait(STATE, EMPTY)</code>. The kernel writes a\nCDP command into the data region, stores <code>DBG_STATE_CMD</code>, and notifies. The\nworker wakes up inside the probe, with the user's stack still intact above it,\nruns the command, and parks again. <code>stepOver</code>, <code>getProperties</code>,\n<code>evaluateOnCallFrame</code>, all of it happens on a thread that is technically in the\nmiddle of executing line 41.</p>\n<p>That gives two transports for one protocol, and the split is by state rather\nthan by message type. A running process receives commands by <code>postMessage</code>,\nbecause it is still turning its event loop and can pick them up. A paused\nprocess receives them through the buffer, because it is not turning anything.\nThere is also a <code>--inspect-brk</code>-style start gate, since a twelve line script\nwould otherwise be finished before the frontend finished attaching.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"why-chrome-devtools-protocol-when-nothing-here-is-chrome\">Why Chrome DevTools Protocol, when nothing here is Chrome<a href=\"https://vivari.run/blog/a-debugger-with-no-inspector#why-chrome-devtools-protocol-when-nothing-here-is-chrome\" class=\"hash-link\" aria-label=\"Direct link to Why Chrome DevTools Protocol, when nothing here is Chrome\" title=\"Direct link to Why Chrome DevTools Protocol, when nothing here is Chrome\" translate=\"no\">​</a></h2>\n<p>Speaking CDP to a runtime with no inspector looks like cargo cult. It was the\nbest decision in this subsystem, and we only found out later.</p>\n<p>The alternative was a bespoke debug API, which would have been smaller and would\nhave fit the shape of what actually exists here. What CDP bought instead was a\ncontract that already had two consumers: the studio's debug panel drives Monaco\ngutter breakpoints, the paused-line highlight, and a VS Code style Call Stack,\nVariables and Watch tree, all in the vocabulary of <code>Debugger.scriptParsed</code>,\n<code>Debugger.paused</code> and <code>Runtime.getProperties</code>. None of that had to be invented.</p>\n<p>Then came Python.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-second-backend-and-the-file-that-did-not-change\">The second backend, and the file that did not change<a href=\"https://vivari.run/blog/a-debugger-with-no-inspector#the-second-backend-and-the-file-that-did-not-change\" class=\"hash-link\" aria-label=\"Direct link to The second backend, and the file that did not change\" title=\"Direct link to The second backend, and the file that did not change\" translate=\"no\">​</a></h2>\n<p><code>.py</code> files now get the same pause, step, inspect and evaluate over the same\nprotocol and the same buffer. The studio is unchanged. It speaks CDP, it keeps\nbreakpoints per virtual filesystem path, and it never had a reason to know what\nlanguage is on the other end of the socket that is not a socket.</p>\n<p>Two things differ from the Node backend, and both differ in the same direction:\nCPython already has what the JavaScript side had to build.</p>\n<p><strong>No instrumentation.</strong> CPython's frames are real. There is no acorn, no probe\nweaving, no shadow call stack, no line number preservation problem. What is left\nis the protocol and the transport, and those were reused as they stood.</p>\n<p><strong>PEP 669, not <code>sys.settrace</code>.</strong> This is the interesting half, because it is the\ndifference between a debugger you can leave attached and one you have to\nremember to turn off. A <code>settrace</code> hook is called on every line of every\nfunction and has no way to say \"stop calling me about this one\". Measured on\nthis build, on one machine, with a 300,000 iteration loop:</p>\n<table><thead><tr><th>what is attached</th><th>cost</th></tr></thead><tbody><tr><td>nothing</td><td>22ms</td></tr><tr><td><code>sys.settrace</code> with a hook that does a dict lookup and returns</td><td>217ms</td></tr><tr><td><code>sys.monitoring</code> answering <code>DISABLE</code></td><td>23ms</td></tr><tr><td><code>sys.monitoring</code> with a breakpoint on the hot line</td><td>83ms</td></tr></tbody></table>\n<p>A debugger that makes the program ten times slower is not observing the program;\nit is changing it. <a href=\"https://peps.python.org/pep-0669/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">PEP 669</a> landed in 3.12\nand this interpreter is 3.14, so the callback can return\n<code>sys.monitoring.DISABLE</code>, which permanently retires that bytecode location. A\nline that is not a breakpoint is asked about exactly once and then costs nothing\nfor the rest of the run. The 83ms row is a breakpoint on the hot line itself,\nwhich is a line you were about to stop on anyway.</p>\n<p>That table is the whole argument, and it is the kind of claim that is easy to\nmake and boring to take on trust. The interpreter below is running in this page,\nand it measures all three for itself. Watch the middle number, and then watch\nthe two counts underneath it:</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">Why a debugger can be left on</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=python-monitoring\" title=\"Why a debugger can be left on\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:560px\"></iframe></div>\n<p>The timings depend on your machine and on what else it is doing, and the ratio\nbetween them is the part that holds. The counts do not depend on anything:\n<code>settrace</code> is handed 600,006 line events and <code>sys.monitoring</code> is handed 10. That\nis the same loop, the same breakpoint table, and five orders of magnitude\nbetween how often the debugger was asked.</p>\n<p>The edit worth making is at the bottom of the file. Put a line number from\ninside <code>hot()</code> into <code>BREAKPOINTS</code> and run it again: the third number climbs,\nbecause that one location stops answering <code>DISABLE</code> and CPython goes back to\nasking about it on every iteration. It does not climb all the way to the\n<code>settrace</code> figure, and the gap between the two is exactly what <code>DISABLE</code> is\nbuying on every other line in the function.</p>\n<p>There is a price for <code>DISABLE</code>, and stepping is where you pay it. Once a\nlocation has been retired it never fires again, so single stepping has to call\n<code>restart_events()</code> to un-retire everything it disabled. Code outside the user's\nproject roots is dropped on its first line, which is what keeps a breakpoint\nanywhere in your file from tracing all of <code>import pandas</code>.</p>\n<p>The hot path stays in Python: a set lookup per candidate line. JavaScript only\ngets involved once a pause has been decided, and then it runs the entire CDP\nconversation by calling back into the interpreter for frames, scopes, reprs and\n<code>eval</code>. JavaScript inside a Python call, calling Python again, is the same\nre-entrant shape as the blocking stdin syscall, which is either reassuring or\nalarming depending on your temperament.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"which-backend-attaches-is-decided-not-guessed\">Which backend attaches is decided, not guessed<a href=\"https://vivari.run/blog/a-debugger-with-no-inspector#which-backend-attaches-is-decided-not-guessed\" class=\"hash-link\" aria-label=\"Direct link to Which backend attaches is decided, not guessed\" title=\"Direct link to Which backend attaches is decided, not guessed\" translate=\"no\">​</a></h2>\n<p><code>python</code> and <code>python3</code> used to be on the debug skip list. They now carry a\n<code>debugLang: \"python\"</code> label that travels with the buffer from the kernel worker\nthrough the process worker into the runtime, and exactly one of the two backends\nattaches.</p>\n<p>Without the label the JavaScript backend does what it is supposed to do, which\nis the wrong thing: <code>python</code> is itself a Node program, so it would instrument\nour own four thousand line launcher shim and offer you breakpoints in it. That is a\ndebugger correctly debugging a program nobody asked about.</p>\n<p>A related bug took a while to find and is worth writing down because the symptom\nwas silence. Running <code>python main.py</code> compiled the script under the name\n<code>main.py</code>, while breakpoints are keyed on absolute virtual filesystem paths. So\nnothing ever matched, no breakpoint ever bound, and the program simply ran to\ncompletion. A debugger that does not stop looks exactly like a debugger that is\nnot attached.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"ctrl-c-into-an-interpreter-that-is-not-in-javascript\">Ctrl-C into an interpreter that is not in JavaScript<a href=\"https://vivari.run/blog/a-debugger-with-no-inspector#ctrl-c-into-an-interpreter-that-is-not-in-javascript\" class=\"hash-link\" aria-label=\"Direct link to Ctrl-C into an interpreter that is not in JavaScript\" title=\"Direct link to Ctrl-C into an interpreter that is not in JavaScript\" translate=\"no\">​</a></h2>\n<p>Signals are the other half of interrupting a program, and CPython broke the\nmodel. The pending signal bitmask is only ever observed by JavaScript, either at\na syscall park or on an event loop turn. A guest running CPython in Wasm is\ndoing neither: the worker thread is inside the interpreter's eval loop and will\nnot return to JavaScript until the Python code finishes. The kernel's only\noption was the one it takes for any guest with no handler, which is to kill it.</p>\n<p>CPython's Emscripten build already solves its half by polling a byte of shared\nmemory and raising <code>KeyboardInterrupt</code> at the next bytecode boundary. So SIGINT,\nand only SIGINT, is mirrored into the first byte of <code>control[5]</code>, previously\nreserved padding in the syscall control block. Wasm is little-endian by\nspecification, so that is byte 20 everywhere this runs. The interpreter clears\nthe byte itself when it acts. Measured latency is about 5ms.</p>\n<p>The handler is registered only while the interpreter is running user code,\nbecause registering it is what tells the kernel not to kill this process, and\nthat is a promise you can only keep while there is an interpreter running to\ntake the interrupt.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-honest-to-claim\">What is honest to claim<a href=\"https://vivari.run/blog/a-debugger-with-no-inspector#what-is-honest-to-claim\" class=\"hash-link\" aria-label=\"Direct link to What is honest to claim\" title=\"Direct link to What is honest to claim\" translate=\"no\">​</a></h2>\n<p>The Node debugger is verified by a spike with 27 assertions covering\ninstrumentation, breakpoint binding including conditional breakpoints,\npause and step, scope and <code>evaluateOnCallFrame</code> including the temporal dead\nzone, a top-level <code>debugger;</code>, the real buffer channel, and an end-to-end\n<code>worker_threads</code> pause, evaluate and resume over that buffer. That is what it is\ntested to do.</p>\n<p>Four limits, by name:</p>\n<ul>\n<li class=\"\"><strong>Preview browser JavaScript cannot be debugged this way, and may never be.</strong>\nA page in the preview iframe runs on a main thread, where <code>Atomics.wait</code> is\nillegal. Pausing it needs a resumable transform, continuation passing or\ngenerators, over the guest's source. Nobody has written that.</li>\n<li class=\"\"><strong>A REPL parked at its prompt still cannot be interrupted.</strong> Ctrl-C works\nwhile the interpreter is running your code. Idle, parked in the blocking stdin\nread, it keeps its old meaning, because interrupting a park needs the read\nitself to return <code>EINTR</code> and it does not do that yet.</li>\n<li class=\"\"><strong>Instrumented code is not your code.</strong> Line numbers survive and the bailout\nkeeps a parse failure from being fatal, but anything that reads its own source\ntext, or measures its own throughput, is measuring the woven version.</li>\n<li class=\"\"><strong>The timings are one machine, one build.</strong> They are here to show that the gap\nbetween 217ms and 23ms is a factor of roughly ten, not to be a benchmark\nanyone should quote.</li>\n</ul>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-general-version\">The general version<a href=\"https://vivari.run/blog/a-debugger-with-no-inspector#the-general-version\" class=\"hash-link\" aria-label=\"Direct link to The general version\" title=\"Direct link to The general version\" translate=\"no\">​</a></h2>\n<p>The Node half of this is a workaround. Instrumenting source to fake a capability\nthe platform withholds is not elegant, and if a browser worker ever grows an\ninspector, most of <code>instrument.js</code> should be deleted that afternoon.</p>\n<p>The part worth keeping is the protocol choice. Picking Chrome DevTools Protocol\nwhen there was no Chrome in the picture cost a little up front and paid for\nitself the day a second language arrived, because the expensive half of a\ndebugger is the frontend, and the frontend never learned that Python existed.\nTwo backends, one of which needs a parser and a shadow stack and one of which\nneeds neither, meet at the same twenty CDP methods and the same three events.</p>\n<p>Picking somebody else's interface when you have no obligation to is usually\noverengineering. It is occasionally the cheapest thing you will ever do.</p>\n<p>More on the runtime in <a href=\"https://vivari.run/docs/how-it-works\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">the architecture docs</a>,\nand the previous post covers\n<a class=\"\" href=\"https://vivari.run/blog/cpython-startup-in-a-tab\">why every <code>import pandas</code> in a tab used to be the first one</a>.</p>\n<hr>\n<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no\nper-seat fee, self-host every asset. The code is on\n<a href=\"https://github.com/maitrungduc1410/vivari\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">GitHub</a> and the\n<a href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Studio</a> runs in your browser.</p>",
            "url": "https://vivari.run/blog/a-debugger-with-no-inspector",
            "title": "A step debugger with no inspector to talk to, and the second SharedArrayBuffer that makes it pause",
            "summary": "There is no V8 inspector in a Web Worker, so a breakpoint had to be built out of acorn probes and a second shared buffer. Then CPython arrived and needed none of it, which is how we found out the protocol was the right thing to have picked.",
            "date_modified": "2026-08-16T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Runtime",
                "Node.js",
                "Python"
            ]
        },
        {
            "id": "https://vivari.run/blog/cpython-startup-in-a-tab",
            "content_html": "<p>On your laptop, the first <code>import pandas</code> of the day is slow and every one after\nit is fast. You have probably never thought about why. CPython compiles the\npackage's <code>.py</code> files to bytecode, writes that bytecode into <code>__pycache__</code>, and\nnever does it again.</p>\n<p>Run Python inside a browser tab, where every command is its own process with its\nown interpreter and a freshly unpacked copy of every package, and something\nuncomfortable follows. There is no second time. Every <code>import pandas</code> is the\nfirst one.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"whose-work-this-is\">Whose work this is<a href=\"https://vivari.run/blog/cpython-startup-in-a-tab#whose-work-this-is\" class=\"hash-link\" aria-label=\"Direct link to Whose work this is\" title=\"Direct link to Whose work this is\" translate=\"no\">​</a></h2>\n<p><a href=\"https://pyodide.org/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Pyodide</a> compiled CPython to WebAssembly, including the C\nextension modules that make NumPy and pandas possible at all. That is theirs,\nand this post does not improve on it. Vivari runs Pyodide's build of CPython\n3.14 unmodified.</p>\n<p>What Vivari adds is the operating system around it: a filesystem that outlives a\nprocess, a process table, and the caches described below. The first half of this\npost uses a Pyodide API and says so. The second half is a CPython behaviour that\nPyodide deliberately turns off, and turning it back on correctly turned out to\nbe the interesting part.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-problem-measured\">The problem, measured<a href=\"https://vivari.run/blog/cpython-startup-in-a-tab#the-problem-measured\" class=\"hash-link\" aria-label=\"Direct link to The problem, measured\" title=\"Direct link to The problem, measured\" translate=\"no\">​</a></h2>\n<p>Every <code>python</code> command in Vivari is a real process with its own worker, and\ntherefore its own interpreter. That is what makes a crashing script harmless,\nand it has an obvious cost: <code>python a.py &amp;&amp; python b.py</code> boots CPython twice.</p>\n<p>On the vendored build, booting CPython costs <strong>1843ms</strong>. That is not a slow\nimport or a slow download. It is the interpreter initialising itself, producing\nthe same bytes it produced the last time and will produce the next time.</p>\n<p>Paying nearly two seconds before a one line script can print anything is the\nsingle worst thing about Python in this environment, and it is worth noticing\nthat it is a pure waste rather than a tradeoff. Nothing about the result varies.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"saving-the-interpreter-instead-of-rebuilding-it\">Saving the interpreter instead of rebuilding it<a href=\"https://vivari.run/blog/cpython-startup-in-a-tab#saving-the-interpreter-instead-of-rebuilding-it\" class=\"hash-link\" aria-label=\"Direct link to Saving the interpreter instead of rebuilding it\" title=\"Direct link to Saving the interpreter instead of rebuilding it\" translate=\"no\">​</a></h2>\n<p>Pyodide can serialise a just-booted interpreter's linear memory and start\nanother interpreter from it. The API is <code>_makeSnapshot</code> on load and\n<code>makeMemorySnapshot()</code> afterwards, it is marked experimental, and it does the\nhard part. Vivari's contribution is narrower: making one process's snapshot\nusable by the next process.</p>\n<p>Measured on the same build:</p>\n<table><thead><tr><th>step</th><th>cost</th></tr></thead><tbody><tr><td>boot CPython normally</td><td>1843ms</td></tr><tr><td>restore from a snapshot</td><td>205ms</td></tr><tr><td>write the 31 MB snapshot</td><td>71ms</td></tr><tr><td>read it back through the VFS</td><td>47ms</td></tr></tbody></table>\n<p>So the first command of a session boots the slow way and keeps the bytes, and\nevery command after it spends about 250ms getting an interpreter instead of\n1843ms. The REPL, <code>pip</code>, <code>pytest</code>, all of them.</p>\n<p><strong>Why the filesystem and not memory.</strong> The snapshot has to outlive the process\nthat made it and be visible to a process that does not exist yet. Those\nprocesses share exactly one thing, which is the virtual filesystem. It goes in\n<code>/var/cache</code>, where the kernel already keeps transient caches and which the\nfilesystem worker excludes from OPFS persistence.</p>\n<p>That exclusion is deliberate rather than incidental. A snapshot is only valid\nfor the interpreter build that made it, and it is 31 MB. Persisting it across\npage reloads would spend a real and permanent storage cost to save 1.6 seconds\non one command per session. So a reload starts cold, on purpose.</p>\n<p><strong>Why it is safe to share between processes.</strong> Restoring a snapshot in a\ndifferent JavaScript realm from the one that made it is the load-bearing\nassumption here, and it is tested rather than hoped: the test tier makes a\nsnapshot in one worker thread and restores it in two others, then imports\npackages, writes files and raises a traceback in each. Two Web Workers are two\nrealms in the same way.</p>\n<p><strong>Two guards, because a corrupt interpreter is a terrible failure mode.</strong> The\nsnapshot bytes are written first and a small JSON sidecar second, which makes\nthe sidecar a commit record: a half-written cache is one whose sidecar does not\nagree with it, and it is ignored rather than restored. Then a restored\ninterpreter is asked to prove it is one:</p>\n<div class=\"language-python codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-python codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token builtin\" style=\"color:rgb(189, 147, 249)\">__import__</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">'json'</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">dumps</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">[</span><span class=\"token builtin\" style=\"color:rgb(189, 147, 249)\">__import__</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">'sys'</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">version_info</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">[</span><span class=\"token number\">0</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">]</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token number\">1</span><span class=\"token plain\"> </span><span class=\"token operator\">+</span><span class=\"token plain\"> </span><span class=\"token number\">1</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">]</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><br></div></code></pre></div></div>\n<p>That costs about a millisecond, and it is deliberately not <code>2 + 2</code>. Arithmetic\nwould survive a restore that had destroyed the import system. This touches the\nfrozen stdlib and string formatting, which is the machinery a bad restore takes\nout. It cannot prove that subtle corruption is absent. What it buys is that an\nobviously broken snapshot costs one cold boot rather than a baffling failure\ninside somebody's own program.</p>\n<p>If anything at all goes wrong, the command boots the slow way and says nothing,\nbecause a cache that has to be explained is a cache with a bug.\n<code>VV_PYTHON_SNAPSHOT=0</code> turns it off.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-bigger-number-underneath\">The bigger number underneath<a href=\"https://vivari.run/blog/cpython-startup-in-a-tab#the-bigger-number-underneath\" class=\"hash-link\" aria-label=\"Direct link to The bigger number underneath\" title=\"Direct link to The bigger number underneath\" translate=\"no\">​</a></h2>\n<p>Removing interpreter start-up exposes the thing it was hiding, which is larger.\nOn the same build:</p>\n<ul>\n<li class=\"\"><code>import pandas</code>: <strong>2.3s</strong></li>\n<li class=\"\"><code>import matplotlib.pyplot</code>: <strong>1.9s</strong></li>\n<li class=\"\"><code>import numpy</code>: <strong>0.5s</strong></li>\n</ul>\n<p>Almost none of that is the package doing anything. It is CPython compiling\naround a thousand <code>.py</code> files to bytecode, having compiled the same files to\nbyte-identical bytecode a moment earlier in a different process.</p>\n<p>CPython solved this decades ago. The reason its solution does not apply here is\none line: Pyodide sets <code>sys.dont_write_bytecode</code>. Which is a perfectly\nreasonable default when every interpreter is thrown away, and exactly wrong once\none of them can leave something behind.</p>\n<p>Unsetting it is free. Measured on numpy, an import with bytecode writing enabled\ncosts 423ms against 420ms with it disabled. So there is no compile step anywhere\nin what follows. There is only keeping what an import already produced.</p>\n<p>Both of those are checkable rather than something to take my word for, so check\nthem. The interpreter below is running in this page, and it prints its own\nversion and the two settings this section is about:</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">Real CPython, and the two settings behind the cache</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=python-interpreter\" title=\"Real CPython, and the two settings behind the cache\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:520px\"></iframe></div>\n<p>It is the standard library only, which is the stable part of Python here. Run it\nonce and you pay for fetching the interpreter and a cold boot. Run it a second\ntime and you are watching the snapshot from the first half of this post do its\njob, because that is a new process with a new interpreter in it.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"where-it-goes-wrong-pyc-files-have-opinions-about-time\">Where it goes wrong: <code>.pyc</code> files have opinions about time<a href=\"https://vivari.run/blog/cpython-startup-in-a-tab#where-it-goes-wrong-pyc-files-have-opinions-about-time\" class=\"hash-link\" aria-label=\"Direct link to where-it-goes-wrong-pyc-files-have-opinions-about-time\" title=\"Direct link to where-it-goes-wrong-pyc-files-have-opinions-about-time\" translate=\"no\">​</a></h2>\n<p>Turning the flag back on and copying the <code>__pycache__</code> tree between processes\ndoes not work, and the reason is the good part of this post.</p>\n<p>A <code>.pyc</code> file records the mtime and size of the source it was compiled from. On\nimport, CPython compares that recorded mtime against the source file's current\nmtime, and if they differ it throws the cached bytecode away and recompiles.\nThis is correct, it is why editing a file takes effect, and it is fatal here.</p>\n<p>Pyodide's <code>loadPackage</code> unpacks the wheel afresh into each new interpreter. So\nthe source files' mtimes are the time of the unpack, which is different on every\nsingle run. Every cached <code>.pyc</code> would be stale the moment it arrived, every\ntime, and the cache would do nothing except cost disk.</p>\n<p><a href=\"https://peps.python.org/pep-0552/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">PEP 552</a> has the answer, and has had it\nsince Python 3.7. A <code>.pyc</code> can be <strong>hash-based</strong> instead of timestamp-based: it\nrecords a hash of the source rather than its mtime. That is precisely what a\npackage installer writes, for precisely this reason, because an installer also\ncannot promise anything about the mtimes of the files it just wrote.</p>\n<p>Converting a timestamp-based <code>.pyc</code> to a hash-based one is header surgery, not\ncompilation. The marshalled code object, which is the whole expensive part, is\nbyte-identical. Only the sixteen byte header changes. Harvesting the entire\nbytecode tree for numpy and pandas costs <strong>115ms</strong>.</p>\n<p>PEP 552 also defines two flavours of hash-based <code>.pyc</code>, checked and unchecked,\nand the choice here matters. A checked one re-reads and re-hashes the source on\nevery import, which is most of the I/O this cache exists to avoid. Unchecked\nfiles are taken on trust.</p>\n<p>The claim being made by choosing unchecked is that a wheel's files do not change\nwhile its version stays the same. That is not a shortcut invented here. It is\nthe same claim pip makes, and the cache is keyed on package name and version so\nthat the claim stays true.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"three-details-that-were-not-obvious\">Three details that were not obvious<a href=\"https://vivari.run/blog/cpython-startup-in-a-tab#three-details-that-were-not-obvious\" class=\"hash-link\" aria-label=\"Direct link to Three details that were not obvious\" title=\"Direct link to Three details that were not obvious\" translate=\"no\">​</a></h2>\n<p><strong>The bytecode does not land next to the source.</strong> <code>sys.pycache_prefix</code> puts it\nin a tree of its own. Otherwise <code>__pycache__</code> directories appear inside the\nuser's project, show up in the file explorer, and get mirrored back into the\nvirtual filesystem as though the script had written them.</p>\n<p>That setting has a trap in it worth writing down, because it fails silently.\nCPython builds the directory tree under the prefix by walking <strong>up</strong> from the\n<code>.pyc</code>'s intended directory until it finds something that already exists. If\nthat walk runs off the top without finding one, it starts creating directories\nrelative to the current working directory instead, says nothing, and no bytecode\nis ever written where you are looking for it. The prefix's root has to exist\nbefore the first import.</p>\n<p><strong>Only installed packages are cached, never your own modules.</strong> The user's own\ncode gets bytecode too, since it is the same interpreter setting, but theirs\nstays in the per-process prefix and dies with the process, keeping CPython's\nordinary mtime checking. A released package's files do not change. Yours change\nconstantly, and a file you just edited must never be at risk of running as a\nstale copy.</p>\n<p><strong>The cache is keyed on the interpreter's magic number</strong> as well as on package\nname and version, because bytecode from a different CPython is not bytecode.</p>\n<p>Like the snapshot, this lives in the session's filesystem and goes when you\nreload, and <code>VV_PYTHON_BYTECODE=0</code> turns it off.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-honest-to-claim\">What is honest to claim<a href=\"https://vivari.run/blog/cpython-startup-in-a-tab#what-is-honest-to-claim\" class=\"hash-link\" aria-label=\"Direct link to What is honest to claim\" title=\"Direct link to What is honest to claim\" translate=\"no\">​</a></h2>\n<p>CPython 3.14, <code>pip</code> and the REPL are shipped as <strong>stable</strong> in Vivari. The\nscientific stack that this post uses for its measurements, NumPy, pandas,\nMatplotlib, SciPy and scikit-learn, is <strong>experimental</strong>, along with <code>pytest</code> and\nthe notebook. The caching described here applies to every Python command either\nway, and it is not what decides those labels.</p>\n<p>Two limits worth stating plainly:</p>\n<ul>\n<li class=\"\"><strong>Both caches are per session.</strong> A page reload starts cold, by design, for the\nreason given above. Neither one is a persistent build cache and neither is\ntrying to be.</li>\n<li class=\"\"><strong>The interpreter snapshot rests on an experimental Pyodide API.</strong> If it\ncannot be made or restored, everything still works and simply costs 1843ms.\nThat fallback is not decoration; it is the reason it was acceptable to build\non an experimental API at all.</li>\n</ul>\n<p>The numbers in this post are from the vendored build on one machine. They are\nhere to show the shape of the problem, which is a factor of about seven on\ninterpreter start-up once the read is counted, not to be a benchmark you should\nquote.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-general-version\">The general version<a href=\"https://vivari.run/blog/cpython-startup-in-a-tab#the-general-version\" class=\"hash-link\" aria-label=\"Direct link to The general version\" title=\"Direct link to The general version\" translate=\"no\">​</a></h2>\n<p>Both halves of this came from the same question, asked twice: what is this\nprogram recomputing that it already computed?</p>\n<p>The interpreter produces identical bytes on every boot. Compiling pandas\nproduces identical bytecode on every run. Neither is a hard problem in\nprinciple, and in both cases the actual work was not making the cache fast but\nmaking it <strong>correct</strong>: a commit record so a half-written snapshot is never\nrestored, a probe so a broken one is caught, hash-based <code>.pyc</code> files so a\ncached compile is never wrongly trusted, and a hard line at the boundary of the\nuser's own code so an edit always wins.</p>\n<p>That last one is the rule the whole thing hangs on. A cache that is occasionally\nwrong about your own source is worse than no cache, by a margin that no amount\nof saved seconds closes.</p>\n<p>More on all of this in <a href=\"https://vivari.run/docs/python\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">the Python docs</a>, and\nthe previous post covers <a class=\"\" href=\"https://vivari.run/blog/python-web-servers-without-sockets\">how Flask, Django and FastAPI serve real requests with\nno socket underneath</a>.</p>\n<hr>\n<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no\nper-seat fee, self-host every asset. The code is on\n<a href=\"https://github.com/maitrungduc1410/vivari\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">GitHub</a> and the\n<a href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Studio</a> runs in your browser.</p>",
            "url": "https://vivari.run/blog/cpython-startup-in-a-tab",
            "title": "There was never a second import pandas, and PEP 552 is why there is now",
            "summary": "Every python command in the browser boots its own CPython, so __pycache__ never got a second run to be fast on. Fixing it took a memory snapshot and a twenty year old detail of how .pyc files decide they are stale.",
            "date_modified": "2026-08-09T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Python",
                "WebAssembly"
            ]
        },
        {
            "id": "https://vivari.run/blog/python-web-servers-without-sockets",
            "content_html": "<p>Every Python web framework bottoms out in the same two lines, whatever it calls\nthem:</p>\n<div class=\"language-python codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-python codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">sock</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">bind</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">host</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> port</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">sock</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">listen</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">backlog</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><br></div></code></pre></div></div>\n<p>A browser tab does not have that. There is no TCP stack in a page, no file\ndescriptor to bind, and no amount of WebAssembly changes it. So the interesting\nquestion is not whether you can run Flask's Python in a browser, because you\ncan. It is what happens when someone types <code>flask run</code>.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-pyodides-and-what-is-not\">What is Pyodide's, and what is not<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#what-is-pyodides-and-what-is-not\" class=\"hash-link\" aria-label=\"Direct link to What is Pyodide's, and what is not\" title=\"Direct link to What is Pyodide's, and what is not\" translate=\"no\">​</a></h2>\n<p>Worth being exact about this up front, because the rest of the post only makes\nsense once the line is drawn.</p>\n<p><a href=\"https://pyodide.org/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Pyodide</a> compiled CPython to WebAssembly. That is their\nwork, it is a large piece of engineering, and nothing here reimplements any of\nit. The interpreter running in Vivari is Pyodide's build of CPython 3.14, with\nits standard library and its C extension modules, unmodified. When this post\nsays \"the interpreter\", it means theirs.</p>\n<p>What Pyodide does not ship, because it cannot, is an operating system: a\nfilesystem shared with other programs, a process table, a port registry, a\nsocket. Vivari is the layer that supplies those. This post is about exactly one\nof them, and it is the one people ask about first.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"a-socket-that-accepts-everything-and-carries-nothing\">A socket that accepts everything and carries nothing<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#a-socket-that-accepts-everything-and-carries-nothing\" class=\"hash-link\" aria-label=\"Direct link to A socket that accepts everything and carries nothing\" title=\"Direct link to A socket that accepts everything and carries nothing\" translate=\"no\">​</a></h2>\n<p>The obvious expectation is that <code>import socket</code> fails and the frameworks fail\nloudly with it. That is not what happens, and the real behaviour is the reason\nthis needed designing rather than documenting.</p>\n<p>There is a <code>socket</code> module, inherited from the POSIX layer underneath the\nWebAssembly build. <code>connect()</code> succeeds. <code>bind()</code> succeeds. <code>listen()</code> succeeds.\nThen no bytes ever move, and <code>select()</code> never reports the socket readable. That\nis not a defect anybody introduced; it is what a POSIX shaped API looks like\nwhen there is no network stack beneath it to say no.</p>\n<p>Point Django's development server at that and it does the worst possible thing.\nIt prints its banner:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">Starting development server at http://127.0.0.1:8000/</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">Quit the server with CONTROL-C.</span><br></div></code></pre></div></div>\n<p>and then answers nothing, for as long as you leave it running. Nothing raised,\nnothing logged, no clue in the output that the server you are looking at is\nincapable of serving. A missing feature that announces itself is a small\nproblem. A missing feature that looks like a working one costs somebody an\nafternoon.</p>\n<p>So the design rule for everything below is that a socket must never be the thing\nwe quietly rely on.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-launcher-is-not-a-python-program\">The launcher is not a Python program<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#the-launcher-is-not-a-python-program\" class=\"hash-link\" aria-label=\"Direct link to The launcher is not a Python program\" title=\"Direct link to The launcher is not a Python program\" translate=\"no\">​</a></h2>\n<p>Here is the piece that makes the rest possible, and it is an accident of how\nVivari is put together rather than anything clever about Python.</p>\n<p><code>python</code> in Vivari is not a Python program. It is a Node program, running on\nVivari's Node-compatible runtime, and it boots Pyodide inside itself. That\nruntime has a real <code>require(\"http\")</code>, a real event loop, and a working\n<code>server.listen(port)</code>, because Vivari's kernel implements virtual ports: an\nExpress app in this environment gets a preview tab, and it does so without a TCP\nstack either.</p>\n<p>Which means the interpreter that cannot bind a port is running inside a process\nthat already can.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">browser  -&gt;  service worker  -&gt;  kernel virtual port</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                                       |</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                                       v</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                          guest Node http.createServer()      &lt;- the \"socket\"</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                                       |</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                                 JSON, base64 body</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                                       v</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                                    Pyodide</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                                       |</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                          WSGI environ / ASGI scope</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                                       v</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">                              your Flask or FastAPI app</span><br></div></code></pre></div></div>\n<p>Nothing in the Python half of that diagram believes it is talking to a network.\nIt is handed a request the way a WSGI server hands one over, because that is\nprecisely what the layer above it has become.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"writing-the-environ-by-hand\">Writing the environ by hand<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#writing-the-environ-by-hand\" class=\"hash-link\" aria-label=\"Direct link to Writing the environ by hand\" title=\"Direct link to Writing the environ by hand\" translate=\"no\">​</a></h2>\n<p>WSGI is a good specification to be stuck with here, because it was never about\nsockets in the first place. PEP 3333 says a server calls\n<code>app(environ, start_response)</code> and reads the iterable that comes back. It says\nnothing about where the bytes came from.</p>\n<p>So the Node side parses the request it received, and the Python side builds the\ndict:</p>\n<div class=\"language-python codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-python codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">environ </span><span class=\"token operator\">=</span><span class=\"token plain\"> </span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">{</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"REQUEST_METHOD\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> d</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">[</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"method\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">]</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"SCRIPT_NAME\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> d</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">get</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"root_path\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"PATH_INFO\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> d</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">[</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"path\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">]</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"QUERY_STRING\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> d</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">get</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"query\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"SERVER_PROTOCOL\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"HTTP/\"</span><span class=\"token plain\"> </span><span class=\"token operator\">+</span><span class=\"token plain\"> d</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">get</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"http_version\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"1.1\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"wsgi.input\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> io</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">BytesIO</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">body</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"wsgi.errors\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> sys</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">stderr</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"wsgi.multithread\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> </span><span class=\"token boolean\">False</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"wsgi.multiprocess\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> </span><span class=\"token boolean\">False</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"wsgi.run_once\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"> </span><span class=\"token boolean\">False</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\"></span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">}</span><br></div></code></pre></div></div>\n<p><code>wsgi.multithread</code> and <code>wsgi.multiprocess</code> are both <code>False</code> and, unusually, both\nare honestly <code>False</code>. There is one interpreter and there are no OS threads, so\nan app that checks those flags before deciding whether a cache can be a plain\ndict gets a true answer rather than a conservative one.</p>\n<p>Request bodies and response bodies cross the JavaScript and Python boundary as\nbase64 inside a JSON string. That is not elegant and it was chosen anyway: JSON\nstrings convert to Python <code>str</code> with no ambiguity, whereas handing typed arrays\nacross the boundary means reasoning about proxy object lifetimes at every call\nsite. The bridge is on the request path, so the failure mode that matters is a\nsubtle one, not a slow one.</p>\n<p>That is a claim you should not take on trust, so here it is running. The code\nbelow is real CPython executing in this page, and you can edit it and run it\nagain. It builds the environ above, hands it to a WSGI application, and passes\nthe whole thing through <code>wsgiref.validate</code>, which is CPython's own PEP 3333\nconformance checker. If the standard library's validator returns without\nraising, what the bridge gives your app is a real WSGI call and not an\nimpression of one.</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">A real WSGI call, with no socket</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=python-wsgi\" title=\"A real WSGI call, with no socket\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:520px\"></iframe></div>\n<p>Two honest notes about that demo. It uses only the standard library, because\nthe CPython core is stable here while Flask, FastAPI and Django are shipped as\nexperimental templates, and a live demo is the wrong place to blur that\ndistinction. And it exercises the conversion rather than the tunnel: the\nenviron, the application call and the validation are the real thing, while the\npart that carries bytes in from the browser is the guest Node server described\nabove. The first run also has to fetch the interpreter, so it is slower than\nthe ones after it, which is the subject of <a class=\"\" href=\"https://vivari.run/blog/cpython-startup-in-a-tab\">the next post</a>.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-asgi-scope-and-the-bug-that-took-longest\">The ASGI scope, and the bug that took longest<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#the-asgi-scope-and-the-bug-that-took-longest\" class=\"hash-link\" aria-label=\"Direct link to The ASGI scope, and the bug that took longest\" title=\"Direct link to The ASGI scope, and the bug that took longest\" translate=\"no\">​</a></h2>\n<p>ASGI needs more of the same, plus one thing that is easy to get wrong and\nproduces a symptom that points nowhere near the cause.</p>\n<p>Previews in Vivari are served under a path prefix, <code>/preview/&lt;port&gt;/</code>. The\npreview tunnel strips that prefix before the request reaches your process and\nsets <code>x-forwarded-prefix</code> so the app can learn what it was mounted under. The\nNode side reads that header and passes it along as <code>root_path</code>.</p>\n<p>The obvious thing to do next is to set <code>scope[\"root_path\"]</code> to the prefix and\n<code>scope[\"path\"]</code> to the path the tunnel handed over. That is wrong, and it is\nwrong in a way that only shows up on <code>Mount()</code>.</p>\n<p>ASGI defines <code>path</code> as the <strong>full</strong> request path, including <code>root_path</code>.\n<code>root_path</code> names the prefix, it does not remove it. Starlette's\n<code>get_route_path()</code> subtracts <code>root_path</code> from <code>path</code> to get the routable\nremainder, so if you hand it a path that has already been stripped, it subtracts\na prefix that is not there. Top-level routes still match, because the subtraction\nfalls through harmlessly. Every <code>Mount()</code> misses, including the <code>StaticFiles</code>\nmount that a FastAPI app usually has, so the app comes up, the JSON endpoints\nwork, and the CSS 404s.</p>\n<p>The fix is to put the prefix back before building the scope:</p>\n<div class=\"language-python codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-python codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">_vv_root </span><span class=\"token operator\">=</span><span class=\"token plain\"> d</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">get</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"root_path\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">_vv_path </span><span class=\"token operator\">=</span><span class=\"token plain\"> _vv_root </span><span class=\"token operator\">+</span><span class=\"token plain\"> d</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">[</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"path\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">]</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">if</span><span class=\"token plain\"> _vv_root </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">else</span><span class=\"token plain\"> d</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">[</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"path\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">]</span><br></div></code></pre></div></div>\n<p>WSGI needs no equivalent, and the reason is a small piece of design history\nworth appreciating. <code>SCRIPT_NAME</code> and <code>PATH_INFO</code> are already the split form:\nthe prefix and the remainder are separate keys, so there is nothing to subtract\nand nothing to get wrong. ASGI collapsed them into one string plus a length\nconvention, and this is the bug that convention buys you.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"no-threads-which-fastapi-has-an-opinion-about\">No threads, which FastAPI has an opinion about<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#no-threads-which-fastapi-has-an-opinion-about\" class=\"hash-link\" aria-label=\"Direct link to No threads, which FastAPI has an opinion about\" title=\"Direct link to No threads, which FastAPI has an opinion about\" translate=\"no\">​</a></h2>\n<p>Starlette and FastAPI let you write <code>def</code> endpoints as well as <code>async def</code> ones.\nA sync endpoint cannot be awaited, so Starlette runs it on a threadpool through\n<code>anyio.to_thread.run_sync</code>, which ends at <code>threading.Thread</code>, which under Pyodide\nraises <code>RuntimeError: can't start new thread</code>.</p>\n<p>That would make every synchronous route in a FastAPI app a 500, which is most\nroutes in most tutorials.</p>\n<p>There is one interpreter and nothing else can be running in it, so the\nthreadpool is not buying isolation here, only a thread that does not exist.\nRunning the callable inline is the correct answer for this execution model:</p>\n<div class=\"language-python codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-python codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">import</span><span class=\"token plain\"> anyio</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">to_thread </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">as</span><span class=\"token plain\"> _vv_att</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\"></span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">async</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">def</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:rgb(80, 250, 123)\">_vv_run_sync</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">func</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token operator\">*</span><span class=\"token plain\">args</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token operator\">**</span><span class=\"token plain\">kwargs</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">return</span><span class=\"token plain\"> func</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token operator\">*</span><span class=\"token plain\">args</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">_vv_att</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">run_sync </span><span class=\"token operator\">=</span><span class=\"token plain\"> _vv_run_sync</span><br></div></code></pre></div></div>\n<p>Starlette reads <code>run_sync</code> at call time rather than binding it at import, so\nthis takes effect for every sync route and every sync dependency, including ones\ndefined after the patch.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-entrypoints-do-not-import-what-they-are-named-after\">The entrypoints do not import what they are named after<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#the-entrypoints-do-not-import-what-they-are-named-after\" class=\"hash-link\" aria-label=\"Direct link to The entrypoints do not import what they are named after\" title=\"Direct link to The entrypoints do not import what they are named after\" translate=\"no\">​</a></h2>\n<p><code>uvicorn</code>, <code>flask</code> and <code>gunicorn</code> exist as commands. None of them imports the\npackage it is named after.</p>\n<div class=\"language-bash codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-bash codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">uvicorn main:app </span><span class=\"token parameter variable\" style=\"color:rgb(189, 147, 249);font-style:italic\">--port</span><span class=\"token plain\"> </span><span class=\"token number\">8000</span><span class=\"token plain\">                   </span><span class=\"token comment\" style=\"color:rgb(98, 114, 164)\"># FastAPI and other ASGI apps</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">flask </span><span class=\"token parameter variable\" style=\"color:rgb(189, 147, 249);font-style:italic\">--app</span><span class=\"token plain\"> main run </span><span class=\"token parameter variable\" style=\"color:rgb(189, 147, 249);font-style:italic\">--port</span><span class=\"token plain\"> </span><span class=\"token number\">8000</span><span class=\"token plain\">               </span><span class=\"token comment\" style=\"color:rgb(98, 114, 164)\"># Flask</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">gunicorn wsgi:application </span><span class=\"token parameter variable\" style=\"color:rgb(189, 147, 249);font-style:italic\">--bind</span><span class=\"token plain\"> </span><span class=\"token number\">0.0</span><span class=\"token plain\">.0.0:8000  </span><span class=\"token comment\" style=\"color:rgb(98, 114, 164)\"># Django and any other WSGI app</span><br></div></code></pre></div></div>\n<p>Each one parses argv the way the real tool does, works out which module and\nattribute you meant, and hands that to the bridge. Calling them shims\nundersells what they have to get right: the observable contract of <code>gunicorn</code> is\n\"your WSGI app is now served on this port\", and that contract is met. What\ncannot be met is gunicorn's process model, so those flags say so out loud rather\nthan being accepted and ignored. <code>-w 4</code> warns that there is exactly one worker.\n<code>--worker-class gevent</code> stops, because serving you a different concurrency model\nthan the one you asked for is not a warning-level event.</p>\n<p>Choosing gunicorn as the WSGI entrypoint rather than writing a <code>django</code> command\nis the reason Django works at all here. gunicorn is the seam every WSGI\nframework already reaches for, so Bottle and Pyramid arrive for free.</p>\n<p>The argv parsing has one decision in it that is more interesting than argv\nparsing has any right to be. To know whether <code>--log-level debug main:app</code> has\ntwo tokens or three, you need to know which flags take a value. gunicorn's own\n<code>--help</code> declares about a dozen store-true flags and several dozen that take a\nvalue, and the shim hardcodes the boolean list rather than the value list.</p>\n<p>That is smaller, but the real reason is which way it fails. Mistake a boolean\nfor a value-taker and it eats the next token, which is the app spec, and the\ncommand exits with <code>no app specified</code>. The user sees that immediately. Mistake a\nvalue-taker for a boolean and its value is left lying in argv to be picked up as\nthe app spec, and the server cheerfully starts serving something nobody asked\nfor. Both are bugs. Only one of them is quiet.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"djangos-runserver-is-refused-on-purpose\">Django's runserver is refused, on purpose<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#djangos-runserver-is-refused-on-purpose\" class=\"hash-link\" aria-label=\"Direct link to Django's runserver is refused, on purpose\" title=\"Direct link to Django's runserver is refused, on purpose\" translate=\"no\">​</a></h2>\n<p><code>python manage.py runserver</code> does not run. It stops and says why, and points at\nthe command that works.</p>\n<p>This is the only refusal in the Python support that blocks something people\ndemonstrably want, so it is worth defending. Every other entrypoint here hands\nyou an app object, which is a thing the bridge can serve. <code>runserver</code> binds the\nsocket itself. Given Pyodide's socket, that means it would start, print its\nbanner, and answer nothing, which is the failure mode described at the top of\nthis post.</p>\n<p>The rest of <code>manage.py</code> is untouched. <code>migrate</code>, <code>makemigrations</code>, <code>shell</code> and\n<code>createsuperuser</code> all run normally, because none of them is a socket.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"keeping-cpythons-own-http-server-minus-the-socket\">Keeping CPython's own HTTP server, minus the socket<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#keeping-cpythons-own-http-server-minus-the-socket\" class=\"hash-link\" aria-label=\"Direct link to Keeping CPython's own HTTP server, minus the socket\" title=\"Direct link to Keeping CPython's own HTTP server, minus the socket\" translate=\"no\">​</a></h2>\n<p><code>python -m http.server</code> is the neatest case, because it shows what the bridge\nmakes possible when the thing being served is already in the standard library.</p>\n<p>Reimplementing a static file server is easy and would have been the wrong\nanswer. The value of <code>-m http.server</code> is that it is the directory listing you\nknow, the <code>mimetypes</code> table you know, the <code>Range</code> and <code>If-Modified-Since</code>\nhandling you know, and the 404 you know. A lookalike is worth much less than the\nreal one.</p>\n<p>So the handler stays and the socket goes. <code>BaseHTTPRequestHandler</code> does all of\nits I/O through <code>self.rfile</code> and <code>self.wfile</code>, which\n<code>StreamRequestHandler.setup()</code> builds from <code>self.connection</code> by calling\n<code>makefile()</code>. It never touches the socket directly. So a socket, as far as that\nclass is concerned, is an object with <code>makefile()</code> and <code>sendall()</code>:</p>\n<div class=\"language-python codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-python codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">class</span><span class=\"token plain\"> </span><span class=\"token class-name\">_VvConn</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token triple-quoted-string string\" style=\"color:rgb(255, 121, 198)\">\"\"\"Everything StreamRequestHandler.setup() asks of a socket, and no more.\"\"\"</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">def</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:rgb(80, 250, 123)\">__init__</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">self</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> data</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">        self</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">_data </span><span class=\"token operator\">=</span><span class=\"token plain\"> data</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">        self</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">out </span><span class=\"token operator\">=</span><span class=\"token plain\"> </span><span class=\"token builtin\" style=\"color:rgb(189, 147, 249)\">bytearray</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">def</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:rgb(80, 250, 123)\">makefile</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">self</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> mode</span><span class=\"token operator\">=</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"rb\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> bufsize</span><span class=\"token operator\">=</span><span class=\"token operator\">-</span><span class=\"token number\">1</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token operator\">*</span><span class=\"token plain\">a</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token operator\">**</span><span class=\"token plain\">k</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">        </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">return</span><span class=\"token plain\"> io</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">BytesIO</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">self</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">_data</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">if</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"r\"</span><span class=\"token plain\"> </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">in</span><span class=\"token plain\"> mode </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">else</span><span class=\"token plain\"> io</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">BytesIO</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">    </span><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">def</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:rgb(80, 250, 123)\">sendall</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">self</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> b</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">:</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">        self</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token plain\">out </span><span class=\"token operator\">+=</span><span class=\"token plain\"> </span><span class=\"token builtin\" style=\"color:rgb(189, 147, 249)\">bytes</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">b</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><br></div></code></pre></div></div>\n<p>Feed it the raw request bytes, let CPython's own <code>SimpleHTTPRequestHandler</code> do\nthe work, and collect the raw response bytes out of <code>out</code>. The same guest Node\nserver carries them that carries Flask's.</p>\n<p>Duck typing gets used to defend some questionable things. This is the case it\nwas invented for.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"two-things-that-fell-out-for-free\">Two things that fell out for free<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#two-things-that-fell-out-for-free\" class=\"hash-link\" aria-label=\"Direct link to Two things that fell out for free\" title=\"Direct link to Two things that fell out for free\" translate=\"no\">​</a></h2>\n<p>Neither of these was designed. Both are consequences of the constraints, noticed\nafterwards, which is usually a sign the layering is right.</p>\n<p><strong>There is an exact moment when the filesystem is consistent.</strong> A served app\nthat writes a file, an upload or a SQLite commit, needs those writes mirrored\nback into the editor. On a normal server there is no clean moment to do that,\nbecause another thread is always mid-write. Here the handler has returned and\nthere are no threads, so the end of a request is a point where \"everything the\napp has written\" is a complete and correct answer. Persistence happens there,\nafter the response bytes are already out, and costs nothing on a request that\nwrote nothing.</p>\n<p><strong><code>--reload</code> works, without a watcher thread or a subprocess.</strong> Real reloaders\nneed both: something to poll the filesystem, and a process to kill and respawn.\nNeither exists here and neither is needed. The virtual filesystem already pushes\nchange notifications, because that is how Vite's dev server sees your edits. And\nthere is no server process to restart, because your app is an object imported\ninto the bridge's process, so a reload re-imports the module and rebinds one\nname. A failed re-import puts the previous modules back and the old app keeps\nserving, which matters because a syntax error in a file you just saved is the\nnormal case rather than the exceptional one.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-honest-to-claim\">What is honest to claim<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#what-is-honest-to-claim\" class=\"hash-link\" aria-label=\"Direct link to What is honest to claim\" title=\"Direct link to What is honest to claim\" translate=\"no\">​</a></h2>\n<p>The Python web frameworks here are shipped as <strong>experimental</strong> templates. That\nis the project's own word for them, it is what the README says, and this post is\nnot the place to quietly upgrade it. Vivari's CPython core, <code>pip</code> and the REPL\nare stable. Flask, FastAPI and Django sit above them and are not.</p>\n<p>The limits worth knowing before you try it:</p>\n<ul>\n<li class=\"\"><strong>Buffered request and response only.</strong> Each request is converted, run and\nreturned whole. No streaming responses, no Server-Sent Events, no WebSocket\nfrom Python. This is a property of the bridge, not a bug in it.</li>\n<li class=\"\"><strong>One request at a time.</strong> One interpreter, no threads.</li>\n<li class=\"\"><strong><code>runserver</code> is refused</strong>, as described above.</li>\n<li class=\"\"><strong>Generate your URLs.</strong> The preview is served under a prefix, and the bridge\ntells your framework what it is, so <code>url_for()</code>, <code>reverse()</code> and\n<code>request.url_for()</code> stay inside the preview. A hardcoded <code>/about</code> escapes it.</li>\n<li class=\"\"><strong>Nothing here makes an unbuilt C extension work.</strong> <code>psycopg2</code> still has no\nwheel. Streamlit still stops on <code>watchdog</code>. This bridge is about serving an\napp you can already import.</li>\n</ul>\n<p>What is not caveated: a real Flask app, a real FastAPI app with real Starlette\nrouting, and a real Django app served through real gunicorn argv handling, all\nanswering real HTTP requests in a browser tab with no server anywhere and no\nsocket underneath.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-pattern-again\">The pattern, again<a href=\"https://vivari.run/blog/python-web-servers-without-sockets#the-pattern-again\" class=\"hash-link\" aria-label=\"Direct link to The pattern, again\" title=\"Direct link to The pattern, again\" translate=\"no\">​</a></h2>\n<p>Every one of these posts has ended in the same place from a different direction.\nThe <a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">synchronous bridge</a> works because <code>Atomics.wait</code> is\na real blocking primitive and the whole system was built to respect it. <a class=\"\" href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser\">Node's\nreal <code>lib/</code></a> works because Node already had a\nseam and we cut along it.</p>\n<p>WSGI and ASGI are that same seam, and Python drew it years before any of this\nexisted, with none of it in mind. The frameworks were already written against an\ninterface that says nothing about sockets. All that was missing was something\nstanding on the other side of it.</p>\n<p>The next post is about a different Python problem entirely: why the first\n<code>python</code> command in a session costs nearly two seconds, why the second one used\nto as well, and what PEP 552 has to do with fixing it. There is more detail on\neverything above in <a href=\"https://vivari.run/docs/python\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">the Python docs</a>.</p>\n<hr>\n<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no\nper-seat fee, self-host every asset. The code is on\n<a href=\"https://github.com/maitrungduc1410/vivari\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">GitHub</a> and the\n<a href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Studio</a> runs in your browser.</p>",
            "url": "https://vivari.run/blog/python-web-servers-without-sockets",
            "title": "Flask, Django and FastAPI answering real requests, with no socket underneath",
            "summary": "Pyodide gave us CPython in the browser. It did not give us a socket, and a Python web server is mostly socket. Here is the bridge that runs WSGI and ASGI apps anyway, and the Starlette bug that took the longest to find.",
            "date_modified": "2026-08-03T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Python",
                "Runtime"
            ]
        },
        {
            "id": "https://vivari.run/blog/nextjs-rsc-in-a-tab",
            "content_html": "<p>For most of this project's life, our notes said Next.js was out of reach. The\nreasoning looked solid: Next compiles with SWC, SWC is native Rust, there is no\nnative code in a browser tab, therefore no Next.js. A hard wall, filed away.</p>\n<p>That verdict was wrong, and it was wrong in the most ordinary way: we had\ndecided something was impossible and then stopped rechecking it.</p>\n<p><code>next dev --webpack</code> now boots inside a browser tab, compiles an App Router\npage, renders React Server Components, and answers <code>GET / → 200</code> with real HTML.\nNo server. The kernel, the filesystem, the process model, the dev server and the\nReact render all live in one tab.</p>\n<p>Getting there was mostly unremarkable engineering, plus one problem that has no\ncorrect solution.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"why-the-wall-was-not-a-wall\">Why the wall was not a wall<a href=\"https://vivari.run/blog/nextjs-rsc-in-a-tab#why-the-wall-was-not-a-wall\" class=\"hash-link\" aria-label=\"Direct link to Why the wall was not a wall\" title=\"Direct link to Why the wall was not a wall\" translate=\"no\">​</a></h2>\n<p>Three things had to be true at once, and all three already were:</p>\n<p><strong>Next 16 kept the Wasm SWC fallback.</strong> <code>@next/swc-wasm-nodejs</code> still exists,\nbecause Next still has to run in environments without a native binding. Next's\nown <code>loadBindings</code> prefers it when <code>process.versions.webcontainer</code> is set,\nwhich our runtime now reports, because that is exactly what we are.</p>\n<p><strong>webpack is still selectable.</strong> Turbopack is native Rust with no Wasm build, so\nit genuinely is unavailable. But <code>--webpack</code> remains a supported flag, and\nwebpack is JavaScript.</p>\n<p><strong>npm skips the native optional dependencies.</strong> On arch <code>wasm32</code>, the\n<code>@next/swc-&lt;platform&gt;</code> optionalDeps do not install, so the Wasm build is not\nmerely preferred, it is the only binding present.</p>\n<p>So the wall was three assumptions that had each expired. Worth remembering next\ntime something gets filed as impossible.</p>\n<p>The rest of the work was the usual: <code>vm.runInNewContext</code> had to make the sandbox\nthe <em>real</em> global, so that <code>globalThis.__RSC_MANIFEST = ...</code> assignments in\nNext's generated manifest files actually land on the context object; without\nthat the client-reference manifest never loads. <code>child_process.fork</code> needed a\ngenuine IPC channel, because <code>next dev</code> forks its dev server and gates startup\non <code>process.send</code> existing. <code>pathToFileURL</code> had to resolve relative to absolute\nlike Node does. A handful of modules had to exist: <code>dns/promises</code>, <code>stream/web</code>,\nan <code>inspector</code> stub, <code>module.findSourceMap</code>, and the complete <code>Console</code> method\nsurface that <code>@edge-runtime/primitives</code> binds.</p>\n<p>All generic. None of it Next-specific. Then there was the interesting one.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-trap\">The trap<a href=\"https://vivari.run/blog/nextjs-rsc-in-a-tab#the-trap\" class=\"hash-link\" aria-label=\"Direct link to The trap\" title=\"Direct link to The trap\" translate=\"no\">​</a></h2>\n<p>The App Router's internals rely on <code>AsyncLocalStorage</code>. Its <code>workStore</code> and\n<code>workUnitAsyncStorage</code> carry per-request context, and React's server rendering\nreads them from deep inside component code. If <code>getStore()</code> returns <code>undefined</code>\nat the wrong moment, you get:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">Expected workStore to be initialized</span><br></div></code></pre></div></div>\n<p>In real Node, <code>AsyncLocalStorage</code> works because V8 exposes a <strong>PromiseHook</strong>.\nThe engine tells <code>async_hooks</code> when a promise is created, resolved, and\ncontinued, so the context can follow execution across a native <code>await</code>. Our\nruntime delegates to the host's <code>async_hooks</code> through the <code>internalBinding</code> seam\nwhen one exists, which is exact.</p>\n<p>A browser has no PromiseHook. There is no way to observe a native <code>await</code>. You\ncannot know that this continuation belongs to that async context, because the\nengine never tells you.</p>\n<p>This is worth sitting with, because it is a genuinely different class of problem\nfrom everything else in this series. Every other gap was work: read Node's\nsource, implement the binding, be precise. This one is a capability the platform\ndoes not have and cannot be made to have.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"three-rules-and-why-each-exists\">Three rules, and why each exists<a href=\"https://vivari.run/blog/nextjs-rsc-in-a-tab#three-rules-and-why-each-exists\" class=\"hash-link\" aria-label=\"Direct link to Three rules, and why each exists\" title=\"Direct link to Three rules, and why each exists\" translate=\"no\">​</a></h2>\n<p>What we could do was stop trying to solve the general problem. A dev preview\nhandles one request at a time. That is a much weaker requirement than a\nproduction server, and within it the context can be tracked well enough to be\n<em>deterministic</em>, not merely usually correct.</p>\n<p>Three rules, each of which exists because a specific thing broke.</p>\n<p><strong>Rule 1: a thenable-returning <code>run(store, cb)</code> holds its store until the\npromise settles, then pops only if still top, and never back to <code>undefined</code>.</strong></p>\n<p>The \"only if still top\" clause stops out-of-order settling from clobbering a\nlive nested scope. The \"never back to <code>undefined</code>\" clause is the one that took\nreal debugging. A streaming RSC render returns its promise <em>early</em>, as soon as\nthe stream is created, while React carries on rendering components detached\nacross native awaits. Zeroing the store when that early promise settles throws\n<code>Expected workStore to be initialized</code> in code that is still running. Restoring\na <em>defined</em> parent store is safe and keeps nested scopes correct; restoring\n<code>undefined</code> is not.</p>\n<p><strong>Rule 2: a plain, non-thenable return does not restore at all.</strong></p>\n<p>Next's <code>renderToFlightStream</code> returns a stream synchronously and does the actual\nrendering later, across raw awaits, with no promise for us to observe. If the\nstore is popped when <code>run()</code> returns, all of that detached work runs with no\ncontext. Leaving the store current keeps <code>getStore()</code> correct until the next\n<code>run()</code> overwrites it.</p>\n<p>This rule is, straightforwardly, a leak. In a general-purpose implementation it\nwould be wrong. Under one-request-at-a-time it is the behaviour that makes the\ndetached render work, and the next <code>run()</code> cleans up after it.</p>\n<p><strong>Rule 3: propagate a per-hop snapshot of every live store through the\nscheduling primitives React uses.</strong></p>\n<p>Specifically <code>Promise.prototype.then</code>, <code>queueMicrotask</code>, <code>setImmediate</code> and\n<code>setTimeout</code>. React's scheduler hops through these constantly, and each hop is a\nplace context would otherwise be lost. Snapshotting at schedule time and\nrestoring at run time recovers most of what a PromiseHook would have given us.</p>\n<p>The patches install <strong>once at boot</strong>, and only on the polyfill path. The timing\nmatters: after the runtime's own timer globals are in place, and before any\nframework code loads, so React captures the wrapped primitives rather than the\noriginals. When the host provides real <code>async_hooks</code>, none of this is active.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"proving-it-given-the-failure-mode\">Proving it, given the failure mode<a href=\"https://vivari.run/blog/nextjs-rsc-in-a-tab#proving-it-given-the-failure-mode\" class=\"hash-link\" aria-label=\"Direct link to Proving it, given the failure mode\" title=\"Direct link to Proving it, given the failure mode\" translate=\"no\">​</a></h2>\n<p>A context bug that only appears under specific interleavings is the worst kind\nto claim you have fixed. \"It worked when I tried it\" is not evidence when the\nmechanism is inherently timing-sensitive.</p>\n<p>So the polyfill is tested by <em>forcing</em> it. <code>VV_NO_HOST_ALS=1</code> disables the host\n<code>async_hooks</code> delegation even where it is available, which means the headless\ntest suite exercises the browser path on a machine where the real one exists.\nThat gives two things: an oracle, and the ability to run the comparison under\nload.</p>\n<p>The specific case that matters is the RSC refresh render: the App Router's\n\"on save\" re-render, the request with <code>RSC: 1</code> that the HMR flow issues. That is\nthe path that threw <code>workStore</code> in the studio, so it is the path that has to be\ngreen.</p>\n<p>The results we hold it to: <code>GET /</code> returns 200; the refresh render returns 200\nwith zero invariant errors across repeats; and the output is <strong>byte-identical</strong>\nto the host <code>async_hooks</code> path, under heavy-I/O perturbation, which is the\nwhole point. Byte-identical against a known-correct implementation is a much\nstronger claim than \"no errors observed\", and it is the only reason we are\ncomfortable describing the behaviour as deterministic rather than lucky.</p>\n<p>One more detail that is easy to get wrong: Next resolves the Wasm SWC by\ndownloading it into its own cache on first compile. That is its intended\nbehaviour in Wasm environments and real Node does the same thing. The template's\n<code>postinstall</code> seeds that cache from the already-installed package so the first\ncompile is offline, with Next's own on-demand download left in place as the\nfallback.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-is-honest-to-claim\">What is honest to claim<a href=\"https://vivari.run/blog/nextjs-rsc-in-a-tab#what-is-honest-to-claim\" class=\"hash-link\" aria-label=\"Direct link to What is honest to claim\" title=\"Direct link to What is honest to claim\" translate=\"no\">​</a></h2>\n<p>Next.js is shipped as a <strong>stable</strong> template, in TypeScript and\nJavaScript, and the caveats are real:</p>\n<ul>\n<li class=\"\"><strong>Turbopack does not work and will not.</strong> It is native Rust with no Wasm\nbuild. <code>--webpack</code> is the path.</li>\n<li class=\"\"><strong>The <code>AsyncLocalStorage</code> polyfill targets a dev preview.</strong> It is correct for\none request at a time. It is not a general-purpose implementation and we would\nnot present it as one.</li>\n<li class=\"\"><strong>First compile is heavy.</strong> A Wasm SWC compiling an App Router page is not\nfast, and you will notice.</li>\n</ul>\n<p>What is not caveated: this is real Next.js from npm, real webpack, real SWC,\nreal React Server Components, rendering real HTML, with no server involved\nanywhere. You can open the Studio and pick the Next.js template right now.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-thread-running-through-all-of-this\">The thread running through all of this<a href=\"https://vivari.run/blog/nextjs-rsc-in-a-tab#the-thread-running-through-all-of-this\" class=\"hash-link\" aria-label=\"Direct link to The thread running through all of this\" title=\"Direct link to The thread running through all of this\" translate=\"no\">​</a></h2>\n<p>Every post in this series has landed in the same place, from a different\ndirection.</p>\n<p>The <a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">synchronous bridge</a> works because\n<code>Atomics.wait</code> is a real blocking primitive and we built the entire system\naround respecting it. <a class=\"\" href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser\">Node's real <code>lib/</code></a>\nworks because Node already had a seam and we cut along it.\n<a class=\"\" href=\"https://vivari.run/blog/real-package-managers-in-the-browser\">npm, yarn and pnpm</a> work because\nthe layers underneath implemented real specifications instead of the subset our\ndemos needed.</p>\n<p><code>AsyncLocalStorage</code> is the exception that proves the rule. There is no seam to\ncut, no specification to implement completely, no primitive to respect. V8's\nPromiseHook simply is not there. So the only honest move was to narrow the\nproblem until it was solvable, be explicit about the boundary, and test against\nthe real implementation rather than against our own expectations.</p>\n<p>Sometimes the interesting engineering is admitting exactly how much of the\nproblem you actually solved.</p>\n<hr>\n<p>Vivari is an open-source, MIT-licensed WebContainer: no commercial licence, no\nper-seat fee, self-host every asset. The code is on\n<a href=\"https://github.com/maitrungduc1410/vivari\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">GitHub</a> and the\n<a href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">Studio</a> runs in your browser.</p>",
            "url": "https://vivari.run/blog/nextjs-rsc-in-a-tab",
            "title": "Next.js 16 renders React Server Components in a browser tab, and the AsyncLocalStorage trap",
            "summary": "We had written Next.js off as a hard native wall. That verdict was wrong. Getting RSC rendering in-browser came down to one primitive the platform cannot provide.",
            "date_modified": "2026-07-27T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Runtime",
                "Node.js"
            ]
        },
        {
            "id": "https://vivari.run/blog/databases-and-the-http-parser",
            "content_html": "<p>Two shorter pieces this time, connected by a theme:\n<a class=\"\" href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser\">running Node's real source</a> keeps paying\nout in places you did not plan for, and WebAssembly turns out to cover a lot of\nwhat \"you need a native binary for that\" used to mean.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"part-one-the-http-parser\">Part one: the HTTP parser<a href=\"https://vivari.run/blog/databases-and-the-http-parser#part-one-the-http-parser\" class=\"hash-link\" aria-label=\"Direct link to Part one: the HTTP parser\" title=\"Direct link to Part one: the HTTP parser\" translate=\"no\">​</a></h2>\n<p>Node's <code>lib/http</code> does not parse HTTP. It delegates to\n<code>internalBinding('http_parser')</code>, which in real Node is llhttp, a C parser\ncompiled into the binary.</p>\n<p>We had written a pure-JavaScript HTTP/1.1 parser to fill that binding. It\nworked, in the sense that ordinary requests went in and ordinary responses came\nout. It also had the failure profile every hand-written protocol parser has: the\ncommon path is fine and the edges are a long tail of things you have not thought\nabout yet. Trailers. <code>HEAD</code> responses, which have <code>Content-Length</code> but no body.\n<code>204</code>, which has neither. Chunked encoding with extensions. Pipelining. Upgrade\nand <code>CONNECT</code> hand-off, where the parser has to stop parsing mid-stream and hand\nthe raw socket over.</p>\n<p>The right answer is to run the same parser Node runs.</p>\n<p><strong>We did not build a toolchain to do it.</strong> Standing up wasi-sdk and clang just\nto recompile llhttp would produce an artifact essentially identical to one that\nalready ships publicly. undici bundles a prebuilt <code>llhttp.wasm</code> from the same\nupstream project, under the same MIT licence. So a vendoring script pins the\nundici version and regenerates a binding module with the binary base64-embedded,\nabout 54 KB. No fetch at runtime, no build dependency.</p>\n<p>Two details made it interesting.</p>\n<p><strong>It has to compile synchronously.</strong> The binding is constructed at process\nbootstrap, inside the\n<a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">synchronous world</a> everything else lives in, so\nthe module is built with <code>new WebAssembly.Module()</code> rather than the async\n<code>compile</code>. That is allowed on a worker thread, which is where guest processes\nrun. On the main thread there is a 4 KB size cap on synchronous compilation, and\na 54 KB module throws, which turns out to be a convenient way to detect the\nenvironment. Exceeding the cap is precisely what trips the pure-JS fallback, so\nthe JS parser stays in the tree as the main-thread path rather than as dead\ncode. <code>VV_HTTP_PARSER=js|wasm</code> forces either side, with <code>wasm</code> failing loudly\ninstead of falling back, so tests can assert which one they exercised.</p>\n<p><strong>The bridge has to be numerically exact.</strong> llhttp reports progress through span\ncallbacks: <code>on_url</code>, <code>on_status</code>, <code>on_header_field</code>, <code>on_header_value</code>,\n<code>on_body</code>, <code>on_headers_complete</code>, <code>on_message_complete</code>. Node's\n<code>lib/_http_common.js</code> does not consume those; it expects a specific set of\nnumeric <code>kOn*</code> slots. The binding mirrors what Node's own <code>node_http_parser.cc</code>\ndoes: drive llhttp's callbacks, fold them onto the exact <code>kOn*</code> contract, for\nboth requests and responses. <code>allMethods</code> follows llhttp's method enum, so\n<code>allMethods[llhttp_get_method()]</code> round-trips the way callers assume.</p>\n<p>When the Wasm backend is live it advertises <code>process.versions.llhttp</code>, exactly\nas real Node does. Twenty offline checks guard it in CI, plus an extended HTTP\ncase covering <code>HEAD</code>, <code>204</code>, chunked requests and responses, trailers and\nkeep-alive, run against both backends, because a fallback nobody tests is a\nfallback that does not work.</p>\n<p>You can watch it parse. The server below binds a port inside this tab and then\nfetches from itself:</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">An in-VM HTTP server</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=http-parser\" title=\"An in-VM HTTP server\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:500px\"></iframe></div>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"part-two-databases\">Part two: databases<a href=\"https://vivari.run/blog/databases-and-the-http-parser#part-two-databases\" class=\"hash-link\" aria-label=\"Direct link to Part two: databases\" title=\"Direct link to Part two: databases\" translate=\"no\">​</a></h2>\n<p>\"No native database\" is the limitation every in-browser runtime lists, and the\ndocumentation usually stops at suggesting workarounds.</p>\n<p>It is worth asking why the limitation exists. The blocker is not SQL. It is\nthat database clients are native addons, and there is no compiler in the tab. So\nthe question becomes: what do you need in order for a Wasm-compiled engine to\nwork, and do we already have it?</p>\n<p>The answer was yes, and it had nothing to do with databases. A Wasm SQL engine\nneeds a real <code>fs</code> to read its data files, a working <code>url</code> module, and the host's\n<code>WebAssembly</code>, all of which exist because of decisions made for other reasons.\nSo this was less a feature than a discovery.</p>\n<p><strong>SQLite via sql.js.</strong> SQLite compiled to Wasm. <code>initSqlJs()</code> finds its <code>.wasm</code>\nnext to itself with <code>locateFile: (f) =&gt; require.resolve('sql.js/dist/' + f)</code>,\nwhich resolves over the virtual filesystem like any other module path.</p>\n<p><strong>PostgreSQL via PGlite.</strong> This is real PostgreSQL, currently 18, compiled to\nWasm. About 16 MB of <code>pglite.wasm</code> and <code>pglite.data</code>, read out of\n<code>node_modules</code> through the virtual filesystem: the package resolves them from\n<code>__filename</code>, builds a <code>new URL('./pglite.wasm', ...)</code>, and calls\n<code>fs.readFile</code>. Every step of that is ordinary Node behaviour, which is the whole\npoint.</p>\n<p>One deliberate choice: we use PGlite's <strong>CommonJS</strong> build. The ESM build relies\non top-level await, and in-VM only the entry module can block on TLA. Choosing\nCJS avoids the problem entirely.</p>\n<p><strong>And one we deliberately did not ship.</strong> libSQL is not available as an in-VM\ntemplate, and the reason is worth stating rather than leaving as a gap in a\ntable. <code>@libsql/client</code> in local mode is a native N-API addon with no <code>wasm32</code>\nbuild. <code>@libsql/client/web</code> works, but only talks to a remote Turso server,\nwhich is a network client, not a database in the tab. Neither is\nself-contained, so neither belongs in a list of things that run with no server.\nsql.js remains the local SQLite path.</p>\n<p>Both engines were confirmed end-to-end in plain Node first, against the same\n<code>fs</code>, <code>url</code> and <code>WebAssembly</code> primitives the runtime exposes, before either was\nwired into a template. Both are then gated by network spikes in CI that install\nthe dependency, bind a port, and assert that the API reports the right engine\nversion and returns seeded rows, with a longer budget for PGlite, whose install\nand first-boot Wasm compile are genuinely heavy.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-pattern\">The pattern<a href=\"https://vivari.run/blog/databases-and-the-http-parser#the-pattern\" class=\"hash-link\" aria-label=\"Direct link to The pattern\" title=\"Direct link to The pattern\" translate=\"no\">​</a></h2>\n<p>Both of these landed for the same reason, and it is not cleverness.</p>\n<p>The HTTP parser worked because Node had already defined the seam\n(<code>internalBinding('http_parser')</code>) and someone had already compiled the C to\nWasm. The databases worked because the runtime implemented <code>fs</code> and <code>url</code>\nproperly rather than implementing the subset our own demos needed.</p>\n<p>Neither was planned. Both fell out of building the layer underneath correctly\nand then discovering what it supported. That is a much better position to be in\nthan the alternative, and it is most of the argument for\n<a href=\"https://vivari.run/docs/how-it-works\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">the architecture</a> these posts\nkeep coming back to.</p>",
            "url": "https://vivari.run/blog/databases-and-the-http-parser",
            "title": "llhttp in Wasm, and a real Postgres in the tab",
            "summary": "Two smaller teardowns. Replacing a hand-written HTTP parser with the same llhttp Node ships, and running PostgreSQL 18 with no server and no native addon.",
            "date_modified": "2026-07-20T00:00:00.000Z",
            "tags": [
                "Teardown",
                "WebAssembly",
                "Runtime"
            ]
        },
        {
            "id": "https://vivari.run/blog/three-ways-to-isolate-a-preview",
            "content_html": "<p>When an in-browser IDE runs your dev server and shows you the result, the\nresult has to be served from <em>somewhere</em>. The easy answer is the origin you\nalready have: put the preview at <code>/preview/5173/</code> on the IDE's own domain, let\nthe Service Worker route by path, ship it.</p>\n<p>That is what we did, and it is a security problem.</p>\n<p>Same origin means the same cookie jar, the same <code>localStorage</code>, the same\nIndexedDB, the same OPFS, the same Cache Storage, the same Service Worker scope.\nPreview code, which includes every npm package the project installed and\nanything an AI assistant just generated, sits inside the IDE's origin. It can\nread the editor's session state, corrupt its persistence, and call its\nsame-origin APIs. Previews are not isolated from the IDE, and they are not\nisolated from each other.</p>\n<p>Fixing this properly took three attempts, and each one ran into a different\npiece of web platform trivia.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"mode-a-same-origin\">Mode A: same origin<a href=\"https://vivari.run/blog/three-ways-to-isolate-a-preview#mode-a-same-origin\" class=\"hash-link\" aria-label=\"Direct link to Mode A: same origin\" title=\"Direct link to Mode A: same origin\" translate=\"no\">​</a></h2>\n<p>The default, and the one to move away from.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">https://ide.example.com/preview/5173/</span><br></div></code></pre></div></div>\n<p>The Service Worker intercepts requests under <code>/preview/&lt;port&gt;/</code>, strips the\nprefix, and relays them to the kernel, which it can find directly because it\nis same-origin with the tab holding it.</p>\n<p>Zero extra infrastructure, and it works. But beyond the storage problem, path\nrouting quietly breaks things that a real server would get right:</p>\n<table><thead><tr><th>Keyed on origin</th><th>Breaks under shared-origin path routing</th></tr></thead><tbody><tr><td>Cookie jar (session, CSRF, <code>SameSite</code>)</td><td>frontend and backend cookies collide at <code>/</code></td></tr><tr><td>localStorage / IndexedDB / OPFS / Cache</td><td>services share one store, state bleeds</td></tr><tr><td>CORS and <code>fetch</code> credentials</td><td>cross-service calls look same-origin, wrongly</td></tr><tr><td>Service Worker scope</td><td>the app's own SW registrations collide</td></tr><tr><td>Absolute paths</td><td>router basename, <code>/asset.png</code> and <code>&lt;base&gt;</code> all break</td></tr></tbody></table>\n<p>That last row is the one users actually report. Any app that assumes it is\nserved from <code>/</code> needs a prefix hack somewhere: in the SW, in the URL rewriter,\nor in the app's own config.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"mode-b-a-second-origin\">Mode B: a second origin<a href=\"https://vivari.run/blog/three-ways-to-isolate-a-preview#mode-b-a-second-origin\" class=\"hash-link\" aria-label=\"Direct link to Mode B: a second origin\" title=\"Direct link to Mode B: a second origin\" translate=\"no\">​</a></h2>\n<p>Move previews to a different origin entirely and the browser's same-origin\npolicy does the work for you. The kernel still lives in the IDE tab, so the\npreview's Service Worker reaches it through a hidden bridge iframe and a\n<code>MessagePort</code>.</p>\n<p>Here the hosting platform pushes back. A Cloudflare Pages project only gets\n<code>&lt;project&gt;.pages.dev</code>, and you cannot mint <code>preview.myproject.pages.dev</code>. A\nsecond origin means a second Pages project.</p>\n<p>Which sounds fine, and introduces the first real trap.</p>\n<p><strong><code>pages.dev</code> is on the Public Suffix List.</strong> That makes <code>myproject.pages.dev</code>\nand <code>myproject-preview.pages.dev</code> <em>different sites</em>, not merely different\norigins. As an isolation boundary that is stronger than you asked for: cookies\ncannot be shared even deliberately.</p>\n<p>It also breaks popping a preview out into its own tab, in a way that cannot be\nworked around.</p>\n<p>Chrome storage-partitions cross-site contexts. The kernel is reached through a\nbridge iframe living in the editor tab; for a standalone preview tab to use that\nbridge's Service Worker registration and <code>MessagePort</code>, both have to be in the\nsame storage partition. Cross-site, they are not. And <code>requestStorageAccess()</code>\nun-partitions <strong>cookies</strong>, not Service Worker registrations, so the \"connect\nthis tab to its project\" gate can never actually bridge the two partitions. The\ngate appears, you grant it, and nothing changes.</p>\n<p><strong>Two subdomains of one registrable domain fix it.</strong> Serve the IDE at\n<code>ide.example.com</code> and previews at <code>preview.example.com</code>, both CNAMEd to their\nPages projects, and the two are <em>same-site</em>: no partition wall exists, so the\npopped-out tab shares the bridge's Service Worker and reaches the kernel with no\ngate at all. Storage is still origin-scoped, so preview code still cannot touch\nIDE storage.</p>\n<table><thead><tr><th>Deploy</th><th>Same-site?</th><th>Partitioned?</th><th>Pop-out</th></tr></thead><tbody><tr><td>Two <code>*.pages.dev</code> projects</td><td>No, PSL cuts <code>pages.dev</code></td><td>Yes</td><td>Gate appears, cannot be granted</td></tr><tr><td>Two subdomains of one domain</td><td>Yes</td><td>No</td><td>Connects immediately, no gate</td></tr><tr><td>StackBlitz (<code>stackblitz.com</code> / <code>webcontainer.io</code>)</td><td>No, different domains</td><td>Yes</td><td>Gate, accepted deliberately</td></tr></tbody></table>\n<p>The residual leak with same-site subdomains is domain-wide cookies: anything set\nwith <code>Domain=example.com</code> is visible to both. So do not set domain-wide cookies\non the IDE. For trusted first-party code this is the right trade. For untrusted\ncode at scale you want the cross-site boundary and you accept the gate, which\nis exactly the choice StackBlitz made, and it is a reasonable one for their\nthreat model, not an oversight.</p>\n<p>There is one important caveat: you have to open the editor at\n<code>ide.example.com</code>. Loading it via the raw <code>.pages.dev</code> hostname reintroduces\ncross-site and the gate comes back.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"mode-c-one-origin-per-port\">Mode C: one origin per port<a href=\"https://vivari.run/blog/three-ways-to-isolate-a-preview#mode-c-one-origin-per-port\" class=\"hash-link\" aria-label=\"Direct link to Mode C: one origin per port\" title=\"Direct link to Mode C: one origin per port\" translate=\"no\">​</a></h2>\n<p>Mode B isolates previews from the IDE, but every preview still shares one origin\nwith every other preview, and the port is still in the path. Mode C gives each\nin-VM port its own origin:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">https://k3f9a2xh--5173-vv.example.com/</span><br></div></code></pre></div></div>\n<p>The port moves into the hostname, so each preview gets genuine\n<code>localhost:&lt;port&gt;</code> semantics, with its own cookies, its own storage and its own\nCORS behaviour, and previews are isolated from each other as well as from the IDE.\nEverything on the \"breaks under path routing\" list above stops being a problem,\nand the prefix hacks come out of the codebase.</p>\n<p>This is the model StackBlitz uses, and their URLs decode neatly:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">https://vitejsvitelqrjey5b-c0kn--5173--87cf54cd.local-credentialless.webcontainer.io/</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">        └────── instance / project id ────┘  └port┘ └session hash┘ └ COEP mode ┘ └base┘</span><br></div></code></pre></div></div>\n<p>Getting there involves three constraints that each look arbitrary until they\nbite.</p>\n<p><strong>Cloudflare Pages cannot do wildcard custom domains.</strong> They are exact hostnames\nonly. So the wildcard origin has to be a Worker bound to a route, serving the\nstatic Service Worker runtime and the bridge document. It runs no kernel and no\nIDE; it is pure static hosting for an origin that has to exist per port.</p>\n<p><strong>The wildcard must be a prefix, so the tag must be a suffix.</strong> Cloudflare\nroutes only allow <code>*</code> at the <em>start</em> of a hostname. <code>vv-*.example.com</code> is an\ninfix wildcard and is rejected. That is why the marker is a suffix and the route\nreads <code>*-vv.example.com/*</code>, which has the pleasant side effect of being narrow:\nit matches Vivari preview hosts and nothing else on the zone. Anything else that\nreaches the Worker is passed straight through untouched.</p>\n<p><strong>Free TLS covers exactly one label.</strong> Cloudflare's Universal SSL issues a\ncertificate for the apex plus a single-level wildcard: <code>*.example.com</code> matches\n<code>abc.example.com</code> but <em>not</em> <code>abc.def.example.com</code>. So a scheme like\n<code>*.preview.example.com</code> is two levels deep, is not covered, and produces a TLS\nerror rather than a helpful message. Paid Advanced Certificate Manager fixes it;\nstaying free means keeping preview hostnames one level under the apex and\npacking the port into that single label, hence <code>&lt;token&gt;--&lt;port&gt;-vv</code>, all in one\nlabel.</p>\n<p>Each preview response is then stamped with <code>COOP: same-origin</code>,\n<code>COEP: credentialless</code> and <code>CORP: cross-origin</code>, so the IDE (which is\n<code>require-corp</code>) can embed the bridge iframe, and the Service Worker is allowed\nto claim root scope.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-limitation-nobody-can-engineer-away\">The limitation nobody can engineer away<a href=\"https://vivari.run/blog/three-ways-to-isolate-a-preview#the-limitation-nobody-can-engineer-away\" class=\"hash-link\" aria-label=\"Direct link to The limitation nobody can engineer away\" title=\"Direct link to The limitation nobody can engineer away\" translate=\"no\">​</a></h2>\n<p>One thing worth being honest about, because it applies to every client-side\ncontainer including this one.</p>\n<p>A preview URL is not a network address. It is a <strong>capability ticket that only\nworks inside the browser holding a kernel-connected Service Worker</strong>. The\nService Worker is a per-origin proxy running in <em>your</em> browser, and its live\nlink to the kernel is a <code>MessagePort</code> held in its memory.</p>\n<p>The consequences are observable on StackBlitz and we inherit all of them:</p>\n<ul>\n<li class=\"\">Paste the URL into a new tab on the same browser and it works, because that\ntab is claimed by the same Service Worker, which already holds the port.</li>\n<li class=\"\">\"Open in new tab\" sometimes needs a popup and a reload. That happens when the\nService Worker's in-memory port has lapsed because it was killed while idle,\nand the popup provides a <code>window.opener</code> channel to re-handshake.</li>\n<li class=\"\">Open it on another machine and it fails, even with the project still open\nelsewhere. The kernel lives in the first machine's tab RAM. <code>postMessage</code> does\nnot cross the network.</li>\n<li class=\"\">Close the editor tab and the preview dies.</li>\n</ul>\n<p>None of that is fixable within the no-server model, because the thing serving\nthe preview genuinely is not a server. A persistent, shareable preview URL\nrequires a real backend, which is a different product decision, not a bug to\nfile.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"choosing\">Choosing<a href=\"https://vivari.run/blog/three-ways-to-isolate-a-preview#choosing\" class=\"hash-link\" aria-label=\"Direct link to Choosing\" title=\"Direct link to Choosing\" translate=\"no\">​</a></h2>\n<table><thead><tr><th></th><th>A. same-origin</th><th>B. shared preview origin</th><th>C. wildcard per port</th></tr></thead><tbody><tr><td>Extra infrastructure</td><td>none</td><td>one more Pages project</td><td>custom domain + wildcard DNS + Worker</td></tr><tr><td>Port encoded in</td><td>path</td><td>path</td><td>hostname</td></tr><tr><td>Isolates IDE from preview</td><td>no</td><td>yes</td><td>yes</td></tr><tr><td>Isolates previews from each other</td><td>no</td><td>no</td><td>yes</td></tr><tr><td>Real per-port semantics</td><td>no</td><td>no</td><td>yes</td></tr></tbody></table>\n<p>The modes are a deploy-time choice rather than a runtime toggle, which keeps the\ncore simple. Start at A if you are running your own trusted code and want zero\ninfrastructure. Move to B with same-site subdomains as soon as anyone else's\ncode runs in your previews. Go to C when previews need to be isolated from each\nother, or when apps genuinely need to believe they are on their own host.</p>\n<p>The <a href=\"https://vivari.run/docs/deployment\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">deployment guide</a> has the full\nsetup for each.</p>",
            "url": "https://vivari.run/blog/three-ways-to-isolate-a-preview",
            "title": "Three ways to isolate a preview, and the Cloudflare wildcard trick",
            "summary": "Serving a preview from the same origin as the IDE hands preview code your cookies, your storage and your OPFS. Fixing it runs into the Public Suffix List, storage partitioning and a TLS wildcard rule.",
            "date_modified": "2026-07-14T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Deployment",
                "Browser platform"
            ]
        },
        {
            "id": "https://vivari.run/blog/real-package-managers-in-the-browser",
            "content_html": "<p>Once you are <a class=\"\" href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser\">running Node's real <code>lib/</code></a> in a\ntab, the interesting question stops being \"can we reimplement npm?\" and becomes\n\"why would we?\". The package managers are just Node programs. If the runtime\nunderneath them is honest, the real CLIs should run unmodified.</p>\n<p>They do. <code>npm</code>, <code>yarn</code>, <code>pnpm</code> and <code>corepack</code> in Vivari are the actual published\nreleases, vendored and executed as-is. But \"should run\" and \"runs\" are separated\nby every Node API each tool happens to touch, and the four of them touch almost\ndisjoint sets. Each one, in turn, walked into a different corner of the runtime\nand found the wall.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"not-a-reimplementation\">Not a reimplementation<a href=\"https://vivari.run/blog/real-package-managers-in-the-browser#not-a-reimplementation\" class=\"hash-link\" aria-label=\"Direct link to Not a reimplementation\" title=\"Direct link to Not a reimplementation\" translate=\"no\">​</a></h2>\n<p>The retired approach was a Turbo-style installer that resolved a lockfile and\nwrote <code>node_modules</code> itself. It was fine for a demo and wrong for a product: it\nwas not npm, so it did not behave like npm, and every gap between the two was a\nsupport burden. The shipped studio boots the real thing instead.</p>\n<p>Delivering a real CLI to a tab is a packaging problem. Each package manager is\ninstalled at a pinned version on the build host, its file tree is walked, and\nthe whole thing is written into one archive that ships as a static asset under\n<code>packages/studio/public/vendor/</code>. The archive is a deliberately boring custom\nformat rather than a tarball:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">[u32le headerLen][header JSON][file bytes ...]      then gzip the whole lot</span><br></div></code></pre></div></div>\n<p>Two decisions in that one line earned their comments. The format is custom\nbecause we control both ends and would rather not meet tar's long-path and\nGNU-extension edge cases, and npm's <code>@npmcli/*</code> paths are long enough to hit them.\nAnd the gzipped output is named <code>*-pack.bin</code>, <strong>not</strong> <code>*.gz</code>, on purpose:</p>\n<blockquote>\n<p>Static servers (Vite's sirv, many CDNs) treat a <code>.gz</code> file as\nTRANSFER-encoded and serve it with <code>Content-Encoding: gzip</code>, so the browser\ntransparently decompresses it before our fetch sees it, and our own gunzip\nthen fails on already-decompressed bytes. A neutral extension is served verbatim.</p>\n</blockquote>\n<p>At boot the kernel worker fetches <code>npm-pack.bin</code>, gunzips it with the browser's\n<code>DecompressionStream</code>, and writes the tree into the virtual filesystem at\n<code>/usr/lib/node_modules/npm</code> in one batched transfer. A three-line shim lands on\n<code>PATH</code> at <code>/bin/npm.js</code> and does nothing but <code>require</code> the real\n<code>bin/npm-cli.js</code>. npm is loaded eagerly because almost every project needs it;\nyarn, pnpm and corepack are registered as lazy loaders and only fetched the\nfirst time you actually spawn them, so the sizes below are costs you opt into.</p>\n<table><thead><tr><th>Tool</th><th>Version</th><th>Delivered</th><th>Files</th></tr></thead><tbody><tr><td>npm</td><td>10.9.2</td><td>~2.8 MB gz</td><td>~2400</td></tr><tr><td>yarn (classic)</td><td>1.22.22</td><td>~1.2 MB gz</td><td>11</td></tr><tr><td>pnpm</td><td>9.15.9</td><td>~3.7 MB gz</td><td>~898</td></tr><tr><td>corepack</td><td>0.35.0</td><td>~0.12 MB gz</td><td>54</td></tr></tbody></table>\n<p>None of that is the hard part. The hard part is that a real CLI calls real Node.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"npm-the-fidelity-pass\">npm: the fidelity pass<a href=\"https://vivari.run/blog/real-package-managers-in-the-browser#npm-the-fidelity-pass\" class=\"hash-link\" aria-label=\"Direct link to npm: the fidelity pass\" title=\"Direct link to npm: the fidelity pass\" translate=\"no\">​</a></h2>\n<p>npm booted first, and getting <code>npm -v</code> to print <code>10.9.2</code> and exit <code>0</code> closed\nthree gaps that a reimplementation would never have surfaced, because they are\nabout <em>being Node</em>, not about installing packages. <code>process</code> had to be a genuine\n<code>EventEmitter</code> (npm's <code>proc-log</code> attaches listeners to it). A dynamic <code>import()</code>\ninside a CommonJS module had to route through our loader (npm does\n<code>await import('chalk')</code>). And <code>stdout.write(cb)</code>, <code>process.exitCode</code> and a single\n<code>'exit'</code> event all had to behave the way npm's exit-handler assumes. Fixing those\nwas less \"supporting npm\" and more \"finishing Node\".</p>\n<p>Then there is the thing every in-browser runtime has to answer for: native\naddons. There is no compiler in a tab, and a <code>.node</code> binary could not be loaded\nif there were, because we run Wasm. But real npm runs a package's\n<code>install</code>/<code>rebuild</code> lifecycle script, which for a native package is\n<code>node-gyp rebuild</code>, and a non-zero exit there aborts the entire install. So\n<code>node-gyp</code> is stubbed to a non-fatal no-op:</p>\n<blockquote>\n<p>To keep installs working we make node-gyp a non-fatal no-op: the build is\nskipped and the script \"succeeds\". This mirrors how browser WebContainers\nhandle native deps: the package's JS fallback (or its wasm32-wasi build,\nauto-selected via optionalDependencies) is what actually loads at runtime.</p>\n</blockquote>\n<p>The rest of npm's needs are environmental. Registry requests go through a\nfetcher that strips the non-safelisted headers a browser would otherwise\npreflight and reject; downloads run through an async fetch op so npm's parallel\ntarball fetches are actually parallel; large writes bypass the shared buffer\npool; and <code>npm_config_audit</code>, <code>npm_config_fund</code> and the update-notifier are\nswitched off, with the cache pointed at an OPFS-backed directory so it survives\na reload.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"yarn-five-gaps-in-one-bundle\">yarn: five gaps in one bundle<a href=\"https://vivari.run/blog/real-package-managers-in-the-browser#yarn-five-gaps-in-one-bundle\" class=\"hash-link\" aria-label=\"Direct link to yarn: five gaps in one bundle\" title=\"Direct link to yarn: five gaps in one bundle\" translate=\"no\">​</a></h2>\n<p>Yarn classic is trivial to <em>deliver</em> (a <code>bin/yarn.js</code> entry and a ~5 MB\nwebpack <code>cli.js</code>, eleven files total) and instructive to <em>run</em>. It exercises a\nset of Node internals npm simply never reached, and lighting it up filled five\nmore compatibility gaps, all fixed down in <code>packages/runtime/</code> where they help\nevery program and not just yarn.</p>\n<p>The memorable one is <code>graceful-fs</code>, which yarn bundles through <code>fs-extra</code>.\n<code>graceful-fs</code> patches <code>fs</code> by subclassing <code>fs.WriteStream</code> with\n<code>fs$WriteStream.apply(this, arguments)</code>, the old prototypal-inheritance move,\nwhich throws against a modern <code>class</code>. Alongside it: <code>process.memoryUsage()</code>\n(yarn's reporter tracks peak memory), and <code>internal/fs/dir</code> for <code>fs.opendir</code>,\nwhich yarn trips indirectly because <code>thenify-all</code> runs <code>promisifyAll(fs)</code> over\n<em>every</em> method it can find. A package manager that reflects over the whole <code>fs</code>\nmodule is an excellent conformance test you did not have to write.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"pnpm-worker-threads-symlinks-and-shell-shims\">pnpm: worker threads, symlinks, and shell shims<a href=\"https://vivari.run/blog/real-package-managers-in-the-browser#pnpm-worker-threads-symlinks-and-shell-shims\" class=\"hash-link\" aria-label=\"Direct link to pnpm: worker threads, symlinks, and shell shims\" title=\"Direct link to pnpm: worker threads, symlinks, and shell shims\" translate=\"no\">​</a></h2>\n<p>pnpm was the one we expected to be hardest, and it was, because it uses the\nfeatures the others avoid. It drives real <code>worker_threads</code> for fetch and\nextract. It builds a <strong>symlinked</strong> <code>node_modules</code>: a content-addressable store\nplus symlinks into it, which means the virtual filesystem has to implement\n<code>symlink</code>, <code>readlink</code> and <code>lstat</code> as first-class operations rather than\napproximations. The store's packages are shared via hard links, so the VFS also\ngrew a real <code>link(2)</code>. What it does <em>not</em> get is reflink/copy-on-write, so the\nprebuilt <code>*.node</code> reflink addons, which only exist for macOS and Windows, are\ndropped at vendor time rather than shipped as dead weight on a Linux target.</p>\n<p>The subtle failure was in how pnpm writes the executables in <code>node_modules/.bin</code>.\nnpm makes them POSIX symlinks to the real <code>.js</code>; pnpm writes a <code>#!/bin/sh</code>\ncmd-shim that <code>exec node \"$basedir/../vite/bin/vite.js\" \"$@\"</code>. Our loader cannot\nrun a shell script, so without help it hands that shell wrapper to the JS\ncompiler and gets <code>SyntaxError: missing ) after argument list</code> the first time\nyou run a pnpm-installed binary. The fix is a small, unit-tested unwrapper that\nparses the target <code>.js</code> out of the shell shim before <code>runMain</code> execs it. The\nonly genuinely missing runtime primitive was <code>util.types.isBoxedPrimitive</code>,\nwhich pnpm's JSON path uses; it went in with the rest of the boxed-primitive and\ntyped-array predicates.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"corepack-a-package-manager-for-package-managers\">corepack: a package manager for package managers<a href=\"https://vivari.run/blog/real-package-managers-in-the-browser#corepack-a-package-manager-for-package-managers\" class=\"hash-link\" aria-label=\"Direct link to corepack: a package manager for package managers\" title=\"Direct link to corepack: a package manager for package managers\" translate=\"no\">​</a></h2>\n<p>corepack is the odd one out, because it is not a package manager at all. It is a\nversion manager: it reads a project's <code>packageManager</code> field, downloads that\nexact yarn or pnpm release, verifies it, and execs it. So it gets only a\n<code>/bin/corepack.js</code> shim and deliberately leaves the direct <code>npm</code>/<code>yarn</code>/<code>pnpm</code>\nshims alone: those stay the defaults, and corepack is the extra \"run the\nproject-pinned version\" path.</p>\n<p>Its download-then-extract-then-exec pipeline surfaced five more gaps, again\nfixed generically: <code>require('module').runMain</code>, which corepack uses to exec the\ndownloaded manager in-process; <code>Readable.fromWeb</code>, so it can stream the tarball\nout of the global <code>fetch()</code> response body; WHATWG stream readers whose\n<code>read()</code>/<code>cancel()</code> promises properly ref the event loop, so a download does not\nrace the loop to exit; and <code>crypto.Hash</code> extending <code>stream.Writable</code>, so the\nidiomatic <code>stream.pipe(createHash(algo))</code> works and the sha512 integrity check\npasses. The one thing our crypto layer cannot do is corepack's registry <strong>ECDSA\nsignature</strong> check (there is no <code>crypto.verify</code>), so the shell sets\n<code>COREPACK_INTEGRITY_KEYS=0</code>, which is corepack's own supported escape hatch. The\ntarball's sha512 integrity is still checked.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-pattern-again\">The pattern, again<a href=\"https://vivari.run/blog/real-package-managers-in-the-browser#the-pattern-again\" class=\"hash-link\" aria-label=\"Direct link to The pattern, again\" title=\"Direct link to The pattern, again\" translate=\"no\">​</a></h2>\n<p>None of these four required code that knows anything about installing packages.\nnpm needed <code>process</code> to be a real <code>EventEmitter</code>; yarn needed <code>graceful-fs</code> to\nbe able to subclass <code>fs.WriteStream</code>; pnpm needed symlinks and hard links to be\nreal filesystem operations; corepack needed WHATWG streams to ref the loop.\nEvery one of those fixes lives in the runtime, not in a shim, so it is there for\nthe next program too, which is exactly why\n<a class=\"\" href=\"https://vivari.run/blog/nextjs-rsc-in-a-tab\">the next thing to run</a> took less work than this did.</p>\n<p>That is the whole bet behind <a class=\"\" href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser\">running Node's real source</a>:\nyou do not implement the tools, you implement the platform, and then the tools\nrun because they were always just programs. The argument is spelled out in more\ndetail in <a href=\"https://vivari.run/docs/how-it-works\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">the architecture docs</a>.</p>",
            "url": "https://vivari.run/blog/real-package-managers-in-the-browser",
            "title": "Real npm, yarn and pnpm in the browser, and how each one broke the runtime",
            "summary": "Not reimplementations. The actual npm, yarn, pnpm and corepack CLIs, vendored and run in a tab. Each one leaned on a different corner of Node, and fixing what it broke improved the whole runtime.",
            "date_modified": "2026-07-06T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Runtime",
                "Node.js"
            ]
        },
        {
            "id": "https://vivari.run/blog/nodes-real-lib-in-the-browser",
            "content_html": "<p>There are two ways to give a browser a Node-compatible runtime, and for a long\ntime we were confidently building the wrong one.</p>\n<p><strong>Path A</strong> is the obvious one: hand-write the core modules. Implement <code>fs</code> on\ntop of your virtual filesystem, implement <code>path</code> as string manipulation,\nimplement <code>events</code> as a small emitter, and keep going. It feels productive\nimmediately. <code>path</code> takes an afternoon. <code>events</code> takes a morning. <code>fs</code> takes a\nweek and mostly works.</p>\n<p>Then you reach <code>stream</code>, and progress stops.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"where-path-a-dies\">Where Path A dies<a href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser#where-path-a-dies\" class=\"hash-link\" aria-label=\"Direct link to Where Path A dies\" title=\"Direct link to Where Path A dies\" translate=\"no\">​</a></h2>\n<p><code>stream</code> is not a big module because Node's authors were verbose. It is big\nbecause backpressure is genuinely hard, and because fifteen years of packages\nhave come to depend on the exact observable behaviour of that difficulty.</p>\n<p>You can write something called <code>Readable</code> in a day. Making it emit <code>'readable'</code>\nat the right moments, respect <code>highWaterMark</code>, handle a <code>pipe</code> target that\nreturns <code>false</code> from <code>write</code>, unpipe cleanly on error, support both flowing and\npaused modes, implement <code>readableEnded</code> versus <code>readableFinished</code>, and behave\ncorrectly when a subclass calls the constructor without <code>new</code>: that is not a\nday. And when you get one of those wrong, you do not get a clean error. You get\na dev server that hangs at 40% of a build, in a package four levels deep in\nsomeone's dependency tree.</p>\n<p><code>http</code> is worse, because it is a protocol parser plus a connection agent plus a\nstream implementation. <code>crypto</code> is worse still, because it is an ABI over\nOpenSSL. <code>zlib</code> needs a compression codec.</p>\n<p>We had a runtime that could run a small Express app and could not run anything\nreal, with a queue of modules ahead of us that each represented months of work\nand would still be, at the end of all that effort, an imitation.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"taking-apart-the-competition\">Taking apart the competition<a href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser#taking-apart-the-competition\" class=\"hash-link\" aria-label=\"Direct link to Taking apart the competition\" title=\"Direct link to Taking apart the competition\" translate=\"no\">​</a></h2>\n<p>StackBlitz's WebContainer had clearly solved this. So we did the obvious thing\nand read their bundle, a ~2.1 MB file called <code>builtins.2896b7f3.js</code>.</p>\n<p>The finding reframed the entire project:</p>\n<p><strong>They do not hand-write Node's core modules. They ship Node's actual <code>lib/</code>\nJavaScript.</strong></p>\n<p>The evidence is not subtle once you look. The bundle exports an object with\nroughly 300 keys, and those keys are not a curated public API. They are Node's\ninternal module tree. Alongside <code>fs</code>, <code>http</code>, <code>stream</code>, <code>crypto</code>, <code>zlib</code>, <code>net</code>,\n<code>tls</code> and <code>worker_threads</code> sit <code>internal/streams/readable</code>, <code>_http_agent</code>,\n<code>internal/crypto/*</code>, and <code>internal/bootstrap/realm</code>. Every module is wrapped in\na function taking <code>(exports, require, module, process, internalBinding, primordials)</code>.</p>\n<p><code>internalBinding</code> and <code>primordials</code> are internal-only Node machinery. You do not\nend up with those identifiers by writing a compatibility layer. You end up with\nthem by shipping Node's source.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-seam\">The seam<a href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser#the-seam\" class=\"hash-link\" aria-label=\"Direct link to The seam\" title=\"Direct link to The seam\" translate=\"no\">​</a></h2>\n<p>Once you see it, the architecture of real Node becomes the architecture of the\nsolution.</p>\n<p>Node is two layers. On top is <code>lib/*.js</code>, tens of thousands of lines of\nJavaScript implementing streams, HTTP, crypto, the module loader, everything a\npackage actually touches. Underneath is C++, reached through exactly one door:</p>\n<div class=\"language-js codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-js codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">const</span><span class=\"token plain\"> binding </span><span class=\"token operator\">=</span><span class=\"token plain\"> </span><span class=\"token function\" style=\"color:rgb(80, 250, 123)\">internalBinding</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"fs\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">;</span><br></div></code></pre></div></div>\n<p><code>lib/fs.js</code> does not know what happens inside <code>internalBinding('fs')</code>. It knows\nthe shape of what comes back. That is a seam, and a seam is something you can\ncut along.</p>\n<p>So: keep Node's JavaScript layer verbatim, and replace the C++ layer underneath\nwith your own implementation. When <code>lib/fs.js</code> calls <code>internalBinding('fs')</code>, it\ngets an object backed by a Rust/Wasm virtual filesystem and a\n<a class=\"\" href=\"https://vivari.run/blog/blocking-in-a-browser\">synchronous shared-memory bridge</a> instead of libuv.</p>\n<p>The economics are what make this decisive. <code>internal/bootstrap/realm</code> lists the\nbindings a running Node needs, and the list is short:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">buffer, cares_wrap, config, constants, contextify, fs, fs_event_wrap,</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">icu, inspector, js_stream, os, pipe_wrap, process_wrap, spawn_sync,</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">stream_wrap, tcp_wrap, tls_wrap, tty_wrap, udp_wrap, uv, zlib</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">(+ async_wrap, crypto, http_parser, signal_wrap, url, v8)</span><br></div></code></pre></div></div>\n<p>Roughly twenty-five bindings, against hundreds of JavaScript modules. Everything\nabove the line is Node's own code, already correct, already battle-tested\nagainst the entire npm ecosystem. All the remaining work is below the line.</p>\n<p>That is the whole trade. Path A means writing hundreds of modules and getting\nthem approximately right. Path B means writing twenty-five bindings and getting\nthem <em>exactly</em> right, because Node's internal callers are unforgiving about\nshapes. Harder in a smaller place.</p>\n<p>Here is the result. Every module below is Node's own source, unmodified,\nexecuting in this page:</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">Node's own core modules</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=real-lib\" title=\"Node's own core modules\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:480px\"></iframe></div>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"why-the-foundation-survived-the-pivot\">Why the foundation survived the pivot<a href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser#why-the-foundation-survived-the-pivot\" class=\"hash-link\" aria-label=\"Direct link to Why the foundation survived the pivot\" title=\"Direct link to Why the foundation survived the pivot\" translate=\"no\">​</a></h2>\n<p>The uncomfortable question when you decide to throw away months of work is how\nmuch of the rest goes with it. In this case, almost none, and understanding why\nis the most useful part of the story.</p>\n<p>The realisation was that our existing <code>fs-client.js</code>, the thing user code called\nto reach the virtual filesystem, was already an <code>internalBinding('fs')</code> in\neverything but name. It took a syscall opcode and arguments, packed them into\nshared memory, parked the thread, and returned bytes or an errno. That is\nprecisely the contract Node's C++ fs binding fulfils.</p>\n<p>The same held everywhere. The Rust virtual filesystem is what <code>internalBinding('fs')</code>\nneeds to sit on. The PID table and process supervisor are what <code>process_wrap</code>\nand <code>spawn_sync</code> need. The virtual network is what <code>tcp_wrap</code> needs. The\nAtomics bridge is what makes any of them able to be synchronous.</p>\n<p>So Path B was a pivot in the upper layers, not a rewrite. Everything below the\nbinding line, the part that had been genuinely hard to build, was the part\nworth keeping. Path A had not been wasted either: writing the hand-rolled\nbuiltins is how we learned what the binding contract actually needed to be.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-it-costs\">What it costs<a href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser#what-it-costs\" class=\"hash-link\" aria-label=\"Direct link to What it costs\" title=\"Direct link to What it costs\" translate=\"no\">​</a></h2>\n<p>Being honest about the other side of the ledger:</p>\n<p><strong>The internal ABI is undocumented and unstable.</strong> You are writing against\nNode's private contract. Nobody upstream owes you compatibility, and pinning to\na Node version is not optional.</p>\n<p><strong>Failures are opaque.</strong> When a binding returns a subtly wrong shape, the error\nsurfaces somewhere in <code>internal/streams/*</code> with a stack trace full of Node\ninternals and no mention of your code. Debugging means reading Node's source,\nwhich is a genuine skill investment.</p>\n<p><strong>Delivery gets heavier.</strong> Node's <code>lib/</code> is a lot of JavaScript to ship into a\ntab, which pushes you into lazy loading and compression decisions you would\nrather not think about.</p>\n<p>Against that: <code>http</code>, <code>stream</code> and <code>crypto</code> work, correctly, for real packages,\ntoday. That trade is not close.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-real-test\">The real test<a href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser#the-real-test\" class=\"hash-link\" aria-label=\"Direct link to The real test\" title=\"Direct link to The real test\" translate=\"no\">​</a></h2>\n<p>Compatibility claims are cheap. The honest test of whether you have a Node\nruntime is not whether <code>hello world</code> prints. It is whether software written by\npeople who assumed a real Node install runs unmodified.</p>\n<p>The hardest such software is the package managers. npm, yarn and pnpm are large,\nold, gnarly programs that touch every corner of the runtime and were absolutely\nnot written with charity toward reimplementations. Getting them to run is the\nsubject of the <a class=\"\" href=\"https://vivari.run/blog/real-package-managers-in-the-browser\">next post</a>, and\neach one broke the runtime in a different, instructive way.</p>",
            "url": "https://vivari.run/blog/nodes-real-lib-in-the-browser",
            "title": "Running Node's real lib/ in a browser tab",
            "summary": "We spent months hand-writing Node's core modules, then took apart a 2.1 MB StackBlitz bundle and threw the approach away. Here is what we found and why we pivoted.",
            "date_modified": "2026-06-25T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Runtime",
                "Node.js"
            ]
        },
        {
            "id": "https://vivari.run/blog/blocking-in-a-browser",
            "content_html": "<p>Every browser-based Node runtime runs into the same wall on day one, and it is\nnot the filesystem, the module resolver, or the process model. It is one line of\ncode:</p>\n<div class=\"language-js codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-js codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token keyword\" style=\"color:rgb(189, 147, 249);font-style:italic\">const</span><span class=\"token plain\"> config </span><span class=\"token operator\">=</span><span class=\"token plain\"> fs</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token method function property-access\" style=\"color:rgb(80, 250, 123)\">readFileSync</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"/app/package.json\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token string\" style=\"color:rgb(255, 121, 198)\">\"utf8\"</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">;</span><br></div></code></pre></div></div>\n<p>That call has to return bytes. Not a promise, not a callback: bytes, on the\nnext line. And reading those bytes means asking something else for them, which\nin a browser means waiting. Browsers are built on exactly one promise to the\nuser: nothing blocks. So the very first thing Node requires is the one thing the\nplatform refuses to do.</p>\n<p>There is precisely one exception, and this post is about building on top of it.</p>\n<!-- -->\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"why-you-cannot-just-make-it-async\">Why you cannot just make it async<a href=\"https://vivari.run/blog/blocking-in-a-browser#why-you-cannot-just-make-it-async\" class=\"hash-link\" aria-label=\"Direct link to Why you cannot just make it async\" title=\"Direct link to Why you cannot just make it async\" translate=\"no\">​</a></h2>\n<p>The obvious dodge is to give up on synchronous I/O: rewrite the runtime around\n<code>fs.promises</code>, tell users to <code>await</code> everything, and move on. It does not work,\nfor a reason that has nothing to do with taste.</p>\n<p>Synchronous I/O is not a convenience in Node; it is load-bearing. <code>require()</code>\nis synchronous all the way down: resolving a specifier means <code>statSync</code> on a\ndozen candidate paths, then <code>readFileSync</code> on the winner, then compiling and\nexecuting it, before the calling module's next statement runs. <code>execSync</code> blocks\na parent until its child exits. <code>zlib.gunzipSync</code>, <code>child_process.spawnSync</code>,\n<code>crypto.randomBytes</code> in its sync form. The whole ecosystem sits on this.</p>\n<p>You cannot make <code>require()</code> async without breaking every CommonJS package ever\npublished, which is most of npm. And you certainly cannot ship the <em>real</em> npm\nCLI, which is what we actually wanted to do. So the synchronous surface is not\nnegotiable. Something has to genuinely block.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-exception-atomicswait-on-a-worker\">The exception: <code>Atomics.wait</code> on a worker<a href=\"https://vivari.run/blog/blocking-in-a-browser#the-exception-atomicswait-on-a-worker\" class=\"hash-link\" aria-label=\"Direct link to the-exception-atomicswait-on-a-worker\" title=\"Direct link to the-exception-atomicswait-on-a-worker\" translate=\"no\">​</a></h2>\n<p>The main thread may not block. A <strong>Web Worker</strong> may.</p>\n<div class=\"language-js codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-js codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token comment\" style=\"color:rgb(98, 114, 164)\">// Only legal off the main thread. This parks the OS thread (no spinning, no</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\"></span><span class=\"token comment\" style=\"color:rgb(98, 114, 164)\">// event loop, nothing runs on it) until someone notifies or it times out.</span><span class=\"token plain\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\"></span><span class=\"token maybe-class-name\">Atomics</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">.</span><span class=\"token method function property-access\" style=\"color:rgb(80, 250, 123)\">wait</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">(</span><span class=\"token plain\">control</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token constant\" style=\"color:rgb(189, 147, 249)\">STATE</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">,</span><span class=\"token plain\"> </span><span class=\"token constant\" style=\"color:rgb(189, 147, 249)\">REQUEST</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">)</span><span class=\"token punctuation\" style=\"color:rgb(248, 248, 242)\">;</span><br></div></code></pre></div></div>\n<p><code>Atomics.wait</code> puts the calling thread to sleep on a word of shared memory. It\nis not a busy-loop and not a trick with <code>XMLHttpRequest</code>; the thread is actually\nparked, and it resumes when another thread calls <code>Atomics.notify</code> on the same\nword. Browsers allow it off the main thread precisely because a blocked worker\ncannot freeze anyone's tab.</p>\n<p>That single primitive is the whole foundation. Run user code on a worker, give\nthat worker a <code>SharedArrayBuffer</code>, and a synchronous call can become: write a\nrequest into shared memory, park, let another thread do the async work, get\nnotified, read the response out. From inside user code, <code>readFileSync</code> returned\nbytes. Nothing async leaked.</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">process code (Web Worker thread)</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">   │  fs.readFileSync(\"/x\")        ← looks synchronous to user code</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">   ▼</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\"> write request into the SAB, Atomics.store(STATE, REQUEST), ring a doorbell</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">   │</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">   ▼  Atomics.wait(STATE, REQUEST)  (the thread genuinely blocks)</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\"> ...another worker services it against the Rust/Wasm VFS...</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">   ▲</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">   └─ writes the response into the SAB, Atomics.notify(STATE)</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">   ▼</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\"> returns bytes (still synchronous, no async leaked to user code)</span><br></div></code></pre></div></div>\n<p>Here it is running for real. Nothing below is awaited, and the round-trip is\ntimed with <code>performance.now()</code> so you can see what a blocking syscall costs\ninside a browser tab:</p>\n<div class=\"vv-playground\"><div class=\"vv-playground__bar\"><span class=\"vv-playground__dot\" aria-hidden=\"true\"></span><span class=\"vv-playground__label\">A synchronous syscall, for real</span><span style=\"flex:1\"></span><a class=\"vv-playground__link\" href=\"https://vivari.run/studio/\" target=\"_blank\" rel=\"noreferrer\">Open in Studio ↗</a></div><iframe class=\"vv-playground__frame\" src=\"/embed/?scenario=sync-fs\" title=\"A synchronous syscall, for real\" loading=\"lazy\" allow=\"cross-origin-isolated\" style=\"height:480px\"></iframe></div>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-tax-cross-origin-isolation\">The tax: cross-origin isolation<a href=\"https://vivari.run/blog/blocking-in-a-browser#the-tax-cross-origin-isolation\" class=\"hash-link\" aria-label=\"Direct link to The tax: cross-origin isolation\" title=\"Direct link to The tax: cross-origin isolation\" translate=\"no\">​</a></h2>\n<p><code>SharedArrayBuffer</code> was restricted after Spectre, and getting it back requires\nthe page to prove it is not sharing a browsing context group with anything it\ndoes not trust. Concretely, every page that hosts the runtime must be served\nwith:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">Cross-Origin-Opener-Policy:   same-origin</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">Cross-Origin-Embedder-Policy: require-corp</span><br></div></code></pre></div></div>\n<p>Miss either header and <code>SharedArrayBuffer</code> is simply <code>undefined</code>. There is no\ndegraded mode to fall back to; the runtime does not start.</p>\n<p>This propagates further than it first appears. <code>require-corp</code> means every\nsubresource on the page must opt in to being embedded, so a third-party image\nwithout a <code>Cross-Origin-Resource-Policy</code> header stops loading. An iframe is only\ncross-origin isolated if its embedder is too, which is why the page you are\nreading carries the headers as well: the live example above is an iframe, and it\nwould not have <code>SharedArrayBuffer</code> otherwise.</p>\n<p>It is worth being blunt about this cost, because it is the part people discover\nlate. Adopting a browser Node runtime is not just an npm install; it is a\ndecision about your document's headers, and therefore about every third-party\nscript, font, and pixel on the page.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"the-layout-and-the-invariant-that-bites\">The layout, and the invariant that bites<a href=\"https://vivari.run/blog/blocking-in-a-browser#the-layout-and-the-invariant-that-bites\" class=\"hash-link\" aria-label=\"Direct link to The layout, and the invariant that bites\" title=\"Direct link to The layout, and the invariant that bites\" translate=\"no\">​</a></h2>\n<p>Every client (the kernel and each process) gets one <code>SharedArrayBuffer</code>, laid\nout as a small control block followed by a data region:</p>\n<div class=\"language-text codeBlockContainer_Ckt0 theme-code-block\" style=\"--prism-color:#F8F8F2;--prism-background-color:#282A36\"><div class=\"codeBlockContent_QJqH\"><pre tabindex=\"0\" class=\"prism-code language-text codeBlock_bY9V thin-scrollbar\" style=\"color:#F8F8F2;background-color:#282A36\"><code class=\"codeBlockLines_e6Vv\"><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">[ control: 4 × Int32 = 16 bytes ][ data region: 1 MiB ]</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\" style=\"display:inline-block\"></span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">control[0] = STATE    (Atomics.wait / notify on this word)</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">control[1] = OPCODE   (which syscall)</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">control[2] = REQ_LEN  (request bytes in the data region)</span><br></div><div class=\"token-line\" style=\"color:#F8F8F2\"><span class=\"token plain\">control[3] = RES_LEN  (response bytes in the data region)</span><br></div></code></pre></div></div>\n<p><code>STATE</code> moves between <code>IDLE</code>, <code>REQUEST</code>, <code>RESPONSE_OK</code> and <code>RESPONSE_ERR</code>; the\nerror case carries a UTF-8 errno like <code>ENOENT</code> so that Node's own error\nconstruction upstack behaves normally. The request frame itself is\nself-describing (<code>[flags:u32][fieldCount:u32]([len:u32][bytes])*</code>) with\nscalars packed little-endian and everything else as raw bytes.</p>\n<p>The interesting part is not the encoding. It is <code>DATA_BYTES = 1 &lt;&lt; 20</code>.</p>\n<p><strong>Every request and every response must fit in one megabyte.</strong> That single\nconstant has caused more bugs than anything else in the system, because it is\ninvisible until a payload crosses it, and payloads cross it constantly:</p>\n<ul>\n<li class=\"\"><strong>File I/O is chunked.</strong> Reads and writes loop at a 512 KiB chunk size, so\narbitrarily large files transfer in pieces. There is a separate <code>writeLarge</code>\npath that skips the shared buffer entirely and transfers an <code>ArrayBuffer</code>\ninstead. That becomes necessary the moment you try to write something like\nyarn's 5 MB bundled <code>cli.js</code> into the filesystem.</li>\n<li class=\"\"><strong>HTTP responses are chunked.</strong> A Vite dev server happily serves a 2.8 MB\npre-bundled dependency file. That body cannot cross in one message, so it is\nsplit into sequential frames reassembled by request id. It also travels as a\nraw length-prefixed field rather than inside JSON: escaping quotes and\nnewlines inflates a body unpredictably, and the failure mode is a silent\noverflow rather than a clean error.</li>\n<li class=\"\"><strong>Downloads bypass the window entirely.</strong> A fetch streams its body straight\ninto the virtual filesystem through a dedicated worker; the caller then reads\nit back with ordinary chunked file reads. This is why npm tarballs of any size\nwork.</li>\n</ul>\n<p>If there is one lesson here, it is that a fixed-size shared window is not a\ndetail of the transport. It is a constraint that reaches up through every layer\nabove it, and every subsystem eventually has to answer for how it handles a\npayload larger than the window.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"who-is-actually-awake\">Who is actually awake<a href=\"https://vivari.run/blog/blocking-in-a-browser#who-is-actually-awake\" class=\"hash-link\" aria-label=\"Direct link to Who is actually awake\" title=\"Direct link to Who is actually awake\" translate=\"no\">​</a></h2>\n<p>Blocking is only safe if the thread you blocked is not the one that has to\nanswer you. So the work is split:</p>\n<ul>\n<li class=\"\">The <strong>main thread</strong> runs the UI and no runtime work at all. It never blocks,\nbecause it never participates.</li>\n<li class=\"\">A <strong>kernel worker</strong> owns the PID table, process supervision, the virtual port\nregistry, and HTTP routing.</li>\n<li class=\"\">A <strong>filesystem worker</strong> owns the Rust/Wasm virtual filesystem. Every client\nregisters its shared buffer with it and wakes it through a <code>MessagePort</code>\ndoorbell.</li>\n<li class=\"\">A <strong>fetcher worker</strong> performs all real outbound network requests, so\ndownloading and decompressing a large tarball never stalls syscall servicing.</li>\n<li class=\"\">Each <strong>process</strong> is its own worker with its own shared buffer, and its own\nevent loop.</li>\n</ul>\n<p>Some syscalls are <em>deferred</em> rather than serviced immediately: <code>accept</code>,\n<code>spawn</code> and blocking <code>fetch</code> leave the caller parked until the awaited event\nactually arrives. That is not a workaround, it is the point: it is how\nblocking <code>accept()</code> and <code>execSync()</code> get their semantics. A parked worker\ncosts nothing while it waits.</p>\n<h2 class=\"anchor anchorTargetStickyNavbar_Vzrq\" id=\"what-this-buys\">What this buys<a href=\"https://vivari.run/blog/blocking-in-a-browser#what-this-buys\" class=\"hash-link\" aria-label=\"Direct link to What this buys\" title=\"Direct link to What this buys\" translate=\"no\">​</a></h2>\n<p>None of the above is exotic on its own. <code>Atomics.wait</code> is a documented API and\n<code>SharedArrayBuffer</code> has shipped for years. What is interesting is how much falls\nout of taking the one available blocking primitive seriously and building the\nentire system to respect it: real <code>require()</code>, real <code>execSync</code>, real child\nprocesses, and, because the synchronous surface is honest, the ability to run\nNode's own source code rather than an approximation of it.</p>\n<p>That last point is the subject of the next post: <a class=\"\" href=\"https://vivari.run/blog/nodes-real-lib-in-the-browser\">running Node's real <code>lib/</code> in\na browser tab</a>, and why hand-writing\n<code>stream</code> and <code>http</code> was never going to work.</p>\n<p>Vivari is MIT-licensed and the code is on\n<a href=\"https://github.com/maitrungduc1410/vivari\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"\">GitHub</a>.</p>",
            "url": "https://vivari.run/blog/blocking-in-a-browser",
            "title": "The one browser API that makes a Node runtime possible",
            "summary": "Node's API is synchronous and browsers refuse to block. Everything Vivari does rests on the single exception to that rule, and on a 1 MiB window.",
            "date_modified": "2026-06-15T00:00:00.000Z",
            "tags": [
                "Teardown",
                "Runtime",
                "Browser platform"
            ]
        }
    ]
}