Skip to content

No-Build Model

WebJs has no bundler, no webjs build command, no output directory. The .js and .ts files you edit are the files the browser fetches. npm run dev and npm run start run the same source. This page is the canonical reference for how it works in practice and why per-file ESM at production scale is competitive with bundling.

Related reading:

  • TypeScript covers the type stripper in detail.
  • Deployment covers HTTP/2 termination, reverse proxies, and Docker.
  • Architecture covers the request lifecycle at a higher level.

The model

WebJs serves source as native ES modules over HTTP. The browser walks the import graph one file at a time, the server transforms each file lazily on first request, and an in-memory cache keyed by mtime makes subsequent requests instant. There is no concatenation, no minification, no chunk graph, no "prepare for production" phase. The Rails 7+ importmap-rails + Hotwire pipeline is the closest analogue.

QuestionAnswer
Is there a build step I run?No. npm run dev and npm run start serve source directly.
Is there a build step the framework runs?Per-file type stripping for .ts, on first request, cached by mtime. That is the only transform.
What about npm packages?Resolved as one consistent graph via jspm.io on first reference. See Bare specifiers below.
How does the browser resolve import '@webjsdev/core'?An <script type="importmap"> emitted in <head> maps the specifier to a URL.
Won't N small files be slow?HTTP/2 multiplex makes per-file serving competitive with bundling. SSR-time modulepreload hints make it parallel.
What does this gain me?What you read is what runs. Granular cache invalidation. Zero build-config files. Edit-and-refresh dev loop.

Request lifecycle (per JS file)

  1. Browser parses HTML, finds <script type="module" src="/app/page.ts"> (or follows an import from one that was already loaded).
  2. Server receives the request. Reads the file from disk.
  3. If the file is .ts / .mts, runs the runtime's stripper: Node 24+'s built-in module.stripTypeScriptTypes, or amaro on Bun (byte-identical). This is whitespace replacement: every (line, column) in the source maps to the same position in the output, so no sourcemap is needed and stack traces are byte-exact. Only erasable TypeScript is supported. enum, namespace with values, parameter properties, legacy decorators, and import = require fail at strip time and the dev server returns a 500 naming the file. The scaffolded tsconfig.json turns on erasableSyntaxOnly: true by default so the compiler rejects these in your own code at edit time. Almost no npm package ships .ts source anyway: published packages compile to .js with sidecar .d.ts type files, which the runtime serves as plain JavaScript with no transform.
  4. Result is cached in memory keyed by (absolute path, mtime). A file edit invalidates naturally.
  5. Response is served as application/javascript with appropriate cache headers. In dev: no-cache (always revalidate, so an edit shows up immediately). In prod: a request that carries the content-hash ?v=<digest> query the framework emits (see below) is served public, max-age=31536000, immutable; a bare un-fingerprinted request falls back to public, max-age=3600. Either way a weak ETag rides along for conditional GET.
  6. Browser executes the module, encounters its imports, repeat from step 1 for each.

The importmap

An <script type="importmap"> is emitted in every SSR response's <head>. It tells the browser how to resolve every bare specifier (anything that isn't a relative ./foo or absolute /bar) to a real URL. WebJs ships a minimal map and extends it dynamically as your app references new npm packages:

<script type="importmap">
{
  "imports": {
    "@webjsdev/core":               "/__webjs/core/index-browser.js",
    "@webjsdev/core/":              "/__webjs/core/src/",
    "@webjsdev/core/client-router": "/__webjs/core/src/router-client.js",
    "@webjsdev/core/directives":    "/__webjs/core/src/directives.js",
    "@webjsdev/core/context":       "/__webjs/core/src/context.js",
    "@webjsdev/core/task":          "/__webjs/core/src/task.js",
    "@webjsdev/core/testing":       "/__webjs/core/src/testing.js",
    "@webjsdev/core/lazy-loader":   "/__webjs/core/src/lazy-loader.js",
    "dayjs":                        "https://ga.jspm.io/npm:[email protected]/dayjs.min.js",
    "zod":                          "https://ga.jspm.io/npm:[email protected]/lib/index.mjs"
  }
}
</script>

The browser resolves every import 'dayjs' through this map. You never write a build config that says "alias dayjs to its dist file". The framework owns the registry, you write idiomatic ESM.

Module graph and modulepreload hints

At server startup, WebJs walks your app source and builds an in-memory graph of file → Set<imported files>. The walker parses import statements with a regex, resolves relative paths, and records every edge. Bare specifiers (npm deps) are not in the app graph, but the exact specifier is recorded as a separate vendor edge so reached npm dependencies can also be preloaded (see below).

When the SSR pipeline renders a page, it computes the components on that page plus their transitive dependencies, and emits one <link rel="modulepreload"> per file:

<link rel="modulepreload" href="/app/page.ts">
<link rel="modulepreload" href="/components/post-card.ts">
<link rel="modulepreload" href="/components/avatar.ts">
<link rel="modulepreload" href="/lib/format-date.ts">

This converts a sequential import waterfall into a parallel fetch. The browser fires every request as soon as the HTML head is parsed, well before <script type="module"> at the bottom would have discovered them.

npm vendor dependencies are preloaded too

The same hinting extends to the npm packages your shipped modules import. WebJs emits a <link rel="modulepreload"> for each reached vendor URL (carrying its SRI integrity and crossorigin), byte-identical to the importmap target so the browser never double-fetches:

<link rel="modulepreload" href="https://ga.jspm.io/npm:dayjs@1/dayjs.min.js" crossorigin integrity="sha384-…">

Only reached vendors are hinted: a package imported solely by a display-only (elided) component, or pinned but never imported, is left out, so this never over-fetches.

The honest caveat vs a bundle. This flattens the first level of the vendor graph (the packages your code imports directly). A vendor's own transitive dependencies are still discovered by parsing each fetched CDN module in turn, level by level, over the cross-origin connection, which is exactly the waterfall a bundler eliminates. So the complementary practice is shallow-dependency discipline: prefer few, shallow ESM dependencies. A library with a flat or one-level graph fully benefits; a deep tree still waterfalls past the first level.

Server-only modules (filename matches .server.{js,ts} or content has a 'use server' directive) are excluded from preload hints, and the dependency walk stops at them: a plain module reached only through a server file (a util that a server action imports) is excluded too. The browser only ever fetches the action's RPC stub, never the server file's own imports, so the preload set is exactly the set the page actually fetches. None of it reaches the browser as source. Lazy components (static lazy = true) are also excluded, since they load on viewport entry via IntersectionObserver, not page load.

Display-only components are excluded too, and not just from preloads. A component whose render() is a pure function of its inputs (no @event handler, no non-state reactive property, no overridden lifecycle hook, no signal / Task / streaming directive, no <slot>) does no client-side work, so its SSR'd HTML is the complete output. The server detects this statically and strips its side-effect import from the served page source, so the browser never downloads the module at all, and any npm package imported only by display-only components drops out of the importmap. This is the no-build equivalent of React Server Components' dead-JS elimination, with no bundler and no server/client directive. The analysis is conservative: anything it cannot prove inert keeps shipping. See Progressive Enhancement.

The module graph is also the authorisation gate

The same graph drives a second purpose: deciding which URLs the dev server is allowed to serve as source. Only files reachable from a page / layout / error / loading / not-found / component entry are servable; everything else 404s before any filesystem operation. This is webjs's equivalent of Next.js's bundler-derived page manifest, computed statically (lazily on the first request, memoized, and re-derived after each fs.watch rebuild) instead of via a build step. The server boots without walking the import graph at all; the first request builds it.

Concretely: GET /package.json, GET /node_modules/<pkg>/index.js, GET /scripts/build.js, and any other file no client code imports return 404 by construction. The model is convention-neutral; if a page imports from src/ or features/, those dirs become servable automatically. No servedDirs config to maintain.

The walk follows static import / export … from edges AND string-literal dynamic imports, so await import('./widget.ts') of an app module is servable (fetched lazily at call time, not eagerly preloaded, because a dynamic import is lazy by intent). A computed specifier (import('./pages/' + name + '.ts')) cannot be resolved statically, so its target is not in the gate and 404s; in dev the 404 carries a hint pointing at the cause and recommending a string-literal import. If you need a computed set of modules client-side, give them a static import map (an object literal of { name: () => import('./pages/name.ts') }) so each branch is a string-literal edge the gate can see.

The .server.{js,ts} stub guardrail still runs as defense in depth: a server file that does reach the gate (because client code imports it for the RPC stub) gets stubbed at request time so its source never crosses the wire.

The graph walker also stops AT server-file boundaries. Files imported only by a .server.{js,ts} file stay out of the gate, since the browser only ever sees the stub for the server file, never its transitive imports. A lib/secrets.ts consumed only by a server action is unreachable to direct URL fetches; a lib/format.ts consumed by both a page and a server action stays reachable through the page edge. Same posture as Next.js, where server-component code lands in separate chunks the client bundle never references.

103 Early Hints

In production, when a GET or HEAD request matches a page route, WebJs sends a 103 Early Hints response before SSR begins. The hints carry Link: <url>; rel=modulepreload headers for the page's modules:

HTTP/1.1 103 Early Hints
Link: </app/page.ts>; rel=modulepreload
Link: </components/post-card.ts>; rel=modulepreload
...

HTTP/1.1 200 OK
Content-Type: text/html
...

The browser starts fetching JS modules while the server is still rendering HTML. By the time the document parser reaches the import statements, those files are already in cache. Most major edges (Cloudflare, fly-proxy, Fastly) forward 103 responses to the client. Early Hints are disabled in dev because file churn could send stale URLs before a rebuild.

Bare specifiers (npm packages)

The browser can't resolve import dayjs from 'dayjs' on its own. WebJs follows the Rails 7 + importmap-rails posture: bare specifiers resolve through an importmap to jspm.io CDN URLs, and the browser fetches the bundle directly from jspm.io. The WebJs server doesn't bundle, cache, or proxy vendor packages.

  1. Scan every .js / .ts file under the app for bare import specifiers (skipping node_modules, .server.{js,ts} files, route.{js,ts} / middleware.{js,ts}, test/, 'use server' modules, type-only imports, and imports inside comments).
  2. For each discovered package, resolve the installed version from node_modules/<pkg>/package.json.
  3. Call api.jspm.io/generate once on the first request with the full install list as a single batch (e.g. ['[email protected]', '[email protected]']), so jspm.io resolves the whole set as one mutually-consistent graph. A directly-imported package and a transitive that needs a newer version of the same package agree on one URL, instead of skewing. jspm.io returns a fully-resolved importmap fragment with correct entry paths. If one install can't be resolved (a private or server-only dep), WebJs falls back to resolving the rest so one bad package never collapses the whole map.
  4. Emit those URLs verbatim in the page's <script type="importmap">. Browser fetches directly from ga.jspm.io; webjs's server is never on the vendor-bytes path.

Native modules and server-only packages (node:*, pg) are filtered out by the scanner (they're imported only from .server.{js,ts} / route.{js,ts} / middleware.{js,ts} files, which the scanner skips). Server packages never reach the browser.

Coherence is also verified as a check, not only at resolution. Resolving the whole install set as one batch (above) produces a coherent graph, but a hand-edited importmap or a partial vendor pin can still skew a transitive version. webjs doctor runs an importmap-coherence check that validates every resolved client dep's declared dependency/peer ranges against the versions actually pinned (including the generated .webjs/vendor/importmap.json), and names the conflicting packages, the required range, and the pinned version when they disagree. So a broken graph is caught before the browser runs it.

Optional: commit resolved URLs via webjs vendor pin

By default the api.jspm.io/generate call happens once on the first request (memoized for the process), never at boot. To skip it entirely (no runtime dependency on jspm.io's API), run webjs vendor pin:

$ webjs vendor pin
Pinning vendor packages from /home/me/my-app...
  [email protected]
  [email protected]
Pinned 2 packages, wrote .webjs/vendor/importmap.json.

This writes .webjs/vendor/importmap.json with the resolved jspm.io URLs. Commit the file to source control. The server reads it from disk on the first request (memoized for the process), never at boot; no api.jspm.io call needed.

The pin output is meant to be committed, so webjs vendor pin keeps it committable for you. The scaffold's .gitignore already excludes the transient .webjs caches (the generated routes.d.ts) while un-ignoring .webjs/vendor/, so a fresh app needs nothing. If your .gitignore would swallow the pins (for example an older or hand-edited one with a blanket .webjs/ line), pinning adds the !.webjs/vendor/ exception for you and tells you to git add .gitignore .webjs/vendor. When the exclusion lives somewhere it cannot patch (a parent repo's .gitignore, or .git/info/exclude), it prints a one-line notice with the exact lines to add instead. A no-vendor app, which never runs this command, is untouched.

For offline-capable production (compliance, air-gapped, strict CSP), add --download:

$ webjs vendor pin --download
Pinning vendor packages from /home/me/my-app (downloading bundles)...
  [email protected]                            8.2 KB
  [email protected]                               12.5 KB
Pinned 2 packages, wrote .webjs/vendor/importmap.json + 2 bundles.

This downloads each bundle from jspm.io to .webjs/vendor/<pkg>@<version>.js. The importmap then points at local /__webjs/vendor/<file>.js URLs; the server serves the committed bundle files. Browser never touches jspm.io at runtime; works fully offline.

Pin is intentionally manual (no predev/prestart auto-run). Auto-pin would cause silent churn in the committed importmap.json as jspm.io resolves URLs or transitive deps drift. Rails takes the same posture: bin/importmap pin is always developer-invoked.

sha384 SRI integrity is on by default, with OR without a pin file. An unpinned (live-resolved) app hashes each cross-origin bundle at warmup and the SSR pipeline stamps the matching hash on each <link rel="modulepreload"> and on the importmap entry itself, so the browser refuses to execute a bundle whose bytes don't match (CDN compromise defense), even before you pin. The live hashing is bounded and fail-open: if a bundle fetch fails (a CDN hiccup), that one URL simply loads without integrity (logged once) and the app still boots. Running webjs vendor pin makes the hashes reproducible and removes the warmup fetch: it writes them alongside the imports in importmap.json under an integrity key (both webjs vendor pin and --download populate it), so the hashes only update when the command is rerun and routine cache-busting cannot drop them.

Switch CDN with --from

If jspm.io has an incident, or you want jsdelivr-served packages, pass a different resolver:

$ webjs vendor pin --from jsdelivr
Pinning vendor packages from /home/me/my-app via jsdelivr...

Accepts jspm (default), jsdelivr, unpkg, or skypack. Same shape as Rails's bin/importmap pin foo --from jsdelivr. The chosen resolver is persisted in importmap.json as a provider sibling field so webjs vendor update targets the same CDN.

Maintenance commands

For pinned packages, three commands stand in for npm audit / npm outdated / npm update:

$ webjs vendor audit
$ webjs vendor outdated
$ webjs vendor update

audit POSTs your pinned versions to the same registry.npmjs.org/-/npm/v1/security/advisories/bulk endpoint npm audit uses, prints any CVEs, and exits non-zero on findings so CI can gate. outdated queries each pinned package's dist-tags.latest and lists what trails. update re-pins every outdated package to its latest, recomputes SRI, and writes the new pin file (you still run npm install <pkg>@<latest> afterward to sync your node_modules).

A heavy client-only library (WebGL, canvas, a big imperative module)

A WebGL scene, a canvas visualization, or any large imperative third-party module (a physics engine, a map renderer) is client-only and must never run at SSR, and with no build step it rides the importmap instead of a bundler. The idiomatic pattern is one path, four parts:

  1. A component SSRs a bare placeholder and boots the engine in the browser only. The component's own module never statically imports the library; it pulls in the engine module through a string-literal dynamic import() inside connectedCallback (which runs only in the browser), so the library never reaches SSR. Wrap the boot so a failure degrades to the SSR placeholder rather than hanging the page. A top-level import of the engine would drag the library into the SSR graph and crash on the first server-only global it touches; the string literal also keeps the engine module authorized by the import-graph gate above (a computed import(expr) 404s).
  2. The engine module is framework-free and statically imports the library by its bare specifier (import * as THREE from 'three'). It is reached only through the browser-only dynamic import, so it never runs server-side.
  3. Vendor the library into the importmap. npm install three, then webjs vendor pin pins three and its three/addons/* subpaths to a single CDN-hosted instance. (If the library is imported straight from a CDN with no local install, webjs vendor pin reports that it found the specifier but could not resolve a version and asks you to install it first.)
  4. Share state between the engine loop and a component through a module-scope signal() used as a container. Every module that imports it gets the same instance. Two rules keep it SSR-safe and cheap: import type anything that transitively reaches the library (a value import would pull it into SSR; the TypeScript stripper erases a type-only import), and mutate the held object in place per animation frame rather than calling .set() 60 times a second (the reading component reads the signal inside its own requestAnimationFrame, outside any reactive render, so no re-render is needed). Use .set() only for a genuine state transition the UI should react to.

The SSR baseline (the placeholder plus any server-rendered content) reads without the engine, so progressive enhancement holds, and the library rides the importmap, so there is still no build step. The full worked recipe with code is in the skill's references/client-router-and-streaming.md.

Why jspm.io and not local bundling?

A stricter "browser-native ESM only" interpretation of no-build would refuse to run any bundler anywhere on the user's machine, including for npm packages. Rails 7+ with importmap-rails is the canonical example, and WebJs adopts the same posture exactly. The WebJs server never invokes a bundler for vendor packages; jspm.io pre-bundled them on their CDN.

Why jspm.io specifically: institutional sponsors (37signals, CacheFly, Socket, Framer), years of uptime, status page at status.jspm.io, standards-first maintenance by Guy Bedford (TC39 ESM + import maps + HTML spec). Same CDN Rails uses.

The framework itself stays no-build in every sense that matters. Source equals runtime for your app code (no compile step before deploy, no output directory, no bundle hashes to invalidate). Vendor packages come pre-bundled from jspm.io. webjs's machine ships zero bundler invocations for vendor traffic, and zero bundler invocations for your own code.

One narrow exception: @webjsdev/core ships pre-built dist/ bundles alongside its src/ in the npm tarball. The browser fetches the framework as ONE self-contained file, /__webjs/core/dist/webjs-core-browser.js (built with code-splitting off, so no chunk-*.js), instead of waterfalling through 15+ src/ files. That single bundle re-exports the whole browser surface, so the bare specifier and the /directives, /context, /task, and /client-router subpaths all resolve to it and each import picks its named exports; only /lazy-loader stays a separate on-demand file. The bare specifier points at a BROWSER entry that drops server-only modules (render-server.js, expose.js, setCspNonceProvider) so server bytes never ride the wire. Node-side consumers resolve via the package's exports field and land on the universal webjs-core.js, which keeps the full surface for the SSR pipeline and unit tests. The readable src/ still ships so AI agents grep it directly. The bundle is built ONCE at npm publish time on the framework author's machine via esbuild as a publish-time devDependency; user installs never invoke a bundler. Workspace dev (monorepo edits) silently falls back to per-file src/ serving until npm run build:dist is run, so the edit-and-refresh loop has no build step. Only @webjsdev/core ships bundles; every other @webjsdev/* package is source-only.

Browser-side env vars without a build step

Next.js exposes NEXT_PUBLIC_* to the browser via build-time static substitution. WebJs has no build step, so it can't substitute literals into source. Instead, the SSR pipeline emits an inline <script> in the document head, before the importmap and any module code:

<script>
  window.process = window.process || {};
  window.process.env = Object.assign(window.process.env || {}, {
    "WEBJS_PUBLIC_API_URL": "https://api.example.com",
    "NODE_ENV": "production"
  });
</script>

After that runs, process.env.WEBJS_PUBLIC_X is a real property read on a real object in the browser. No transform, no substitution, no build step. Same source equals runtime invariant as everything else on this page.

Only env vars with the WEBJS_PUBLIC_ prefix cross the wire. Everything else stays on the server. NODE_ENV is also defined so vendor bundles that probe it (lit, react, etc.) run cleanly in the browser. Full user-facing docs in Configuration.

Granular cache invalidation

The killer feature of the no-build model is what happens between two deploys. With a bundler, edit one component and the entire bundle's content hash changes, so every user re-downloads everything. With per-file ESM:

  • In prod the framework appends a per-file content hash to every same-origin asset URL it emits (the importmap targets, the <link rel="modulepreload"> hrefs, the boot module specifiers) as a ?v=<digest> query, computed at serve time from the file bytes. A fingerprinted URL is served public, max-age=31536000, immutable, so the browser / CDN holds it for a year without revalidating.
  • The file you edited has new bytes, so its digest changes, so its emitted URL changes, so a returning client fetches the new URL instead of serving the stale immutable copy. This is what makes immutable safe with no build step: the hash IS the version. The framework's own @webjsdev/core runtime is fingerprinted too, which fixes the exact regression an un-versioned immutable would cause (a year-pinned old core renderer running against a server emitting the new SSR shape after a version bump).
  • Every other file is byte-identical to the previous deploy, so its digest (and URL) is unchanged and the browser serves the cached copy with no request at all.
  • npm package URLs (jspm.io URLs include @<version>) change only when you bump the package, so browser caches invalidate automatically on version bump. A cross-origin vendor URL is NEVER fingerprinted (jspm already versions it; #235's SRI integrity is keyed by the un-hashed URL).

Result: a typo fix in one component re-downloads exactly one file. A dependency upgrade re-downloads exactly one vendor bundle. A full deploy that touches two components costs two file downloads, not a megabyte of cache-busted bundle. Dev is unaffected: webjs dev emits no ?v and serves every module no-cache so an edit shows up immediately.

HTTP/2 at the edge

Per-file ESM is competitive with bundling only over HTTP/2 (or HTTP/3). On HTTP/1.1 the browser limits concurrent connections per origin to six, so a hundred-file page serializes into sixteen waves. On HTTP/2 the same hundred files multiplex over one TCP connection, with header compression amortizing the per-request overhead.

WebJs delegates TLS termination and HTTP/2 negotiation to the proxy in front of it. webjs start itself speaks plain HTTP/1.1 to its upstream. The full deployment story (PaaS edges, nginx, Caddy, Traefik configs) lives in the Deployment doc.

Dev vs prod

AspectDev (webjs dev)Prod (webjs start)
TS strippingSame: module.stripTypeScriptTypesSame
Mtime cacheCleared on file change via fs.watchPersists for process lifetime
Vendor resolutionReads .webjs/vendor/importmap.json if present; else calls api.jspm.io/generate on the first request (re-resolved after rebuild). Never at boot.Reads .webjs/vendor/importmap.json if present; else calls api.jspm.io/generate on the first request, once. Never at boot.
Cache-Controlno-cachemax-age=31536000, immutable for a content-hashed ?v=<digest> URL (the form the framework emits) and for --download bundles; max-age=3600 for a bare un-fingerprinted request; jspm.io controls headers for direct CDN fetches
103 Early HintsDisabled (stale URL risk)Enabled
CompressionOffBrotli/Gzip negotiated
Live reloadSSE-driven full page reloadn/a

When this falls down

The no-build model is well-suited to apps that fit the framework's assumptions. Concrete limitations to be aware of:

  • HTTP/1.1 only deploys are slow. If you can't put a reverse proxy or PaaS edge in front, per-file ESM will serialize on connection limits. Either accept the latency, deploy on PaaS, or front webjs start with nginx / Caddy.
  • Very large apps with deep import graphs. The modulepreload hint list grows with transitive deps. On a page that touches a hundred files, you ship a hundred preload links. Browsers handle this fine; some CDNs cap header size around 8 KB, which is a soft ceiling of roughly 80 preloads per page. The framework deduplicates against the boot script's imports to keep the list tight.
  • Non-erasable TypeScript in third-party deps. A .ts file in node_modules that uses enum, parameter properties, or other non-erasable syntax fails at strip time and the dev server returns a 500 naming the file and pointing at the no-non-erasable-typescript lint rule. WebJs is buildless end-to-end and has no bundler fallback. Doubly rare in practice: published npm packages almost always ship compiled .js + .d.ts, not raw TypeScript, so the runtime never sees a .ts file from node_modules to begin with. The realistic trigger is a monorepo-internal workspace package that exports .ts source with non-erasable syntax (raw .ts using only erasable syntax goes through the stripper just fine).
  • Tree-shaking is per-file, not bundle-wide. If you import a large utility module for one function, the whole module ships. Either import named symbols from a more focused entry point, or accept it. The mtime cache means repeat fetches are free, so the cost is one-time.

None of these are show-stoppers, and none of them benefit from introducing a bundler. The framework is explicitly designed to make per-file ESM the right answer at production scale.

What is deliberately not in scope

  • No webjs build command. Production performance comes from HTTP/2 multiplex + modulepreload hints, not concatenation. There is no plan to add one.
  • No per-route code splitting beyond what the browser already does. The import graph already loads each module on demand. Modulepreload hints are emitted per-route at SSR time, so the browser fetches exactly the files that route needs.
  • No Vite-grade HMR. Web components can only be customElements.define'd once per page, so the dev server does a full reload via SSE. Reloads are sub-100ms in practice.

If a large-app performance problem ever materializes, the answer will be tightening the modulepreload graph or adopting per-route importmap scopes natively in the browser. Not reintroducing a build step.