A build with no bundler

The site ships eighteen kilobytes of JavaScript across four modules, built by a bundler with a configuration file, a plugin, a watch process and a dependency tree of a hundred and eighty packages. The build works and has worked for years, which is why nobody had asked whether it is required.

The symptom

$ ls src/*.ts
  main.ts  filter.ts  accordion.ts  dom.ts

$ wc -c dist/assets/*.js | tail -1
  18412 total

$ npm ls --all --omit=dev 2>/dev/null | wc -l
  188

$ time npm run build
real    0m1.104s

A hundred and eighty packages and a configuration file for eighteen kilobytes of output. The build is fast and the dependency surface is not proportional to anything — it is proportional to the tool rather than to the work.

Why it happens

A build step is the default for anything with a .ts extension, and the requirement is never re-derived. The tool was correct when the project had a framework and a component library, and both of those left in 2023.

The fix

What a bundler actually provides

  module resolution        bare specifiers → paths
  transpilation            TypeScript → JavaScript
  bundling                 many modules → one file
  tree shaking             removing unused exports
  minification
  content hashing          cache-busting filenames
  a dev server with HMR
  CSS handling, asset
    imports, env
    substitution, and a
    plugin ecosystem

of which we use: transpilation, minification, hashing,
and module resolution for three bare specifiers.

Bundling and tree shaking are the two that justify a bundler, and both are about dependencies — with no third-party runtime dependencies there is nothing to shake and four modules is not a request problem. The dev server is genuinely useful and is replaceable by a file watcher.

The three that still need a tool

#!/usr/bin/env bash
set -euo pipefail

esbuild src/main.ts src/filter.ts src/accordion.ts 
  --minify --format=esm --target=es2022 
  --entry-names='[name]-[hash]' 
  --outdir=dist/assets 
  --metafile=dist/meta.json

jq -r '
  .outputs | to_entries[]
  | select(.value.entryPoint)
  | "(.value.entryPoint | sub("src/";"") | sub("\.ts$";"")) (.key | sub("dist/";""))"
' dist/meta.json > dist/manifest.txt

The metafile is the piece that makes hashed filenames usable — something has to tell the template which file to reference, and that is the one bundler feature a single command does not replace. Twelve lines and one binary, against a configuration file and a hundred and eighty packages.

Module resolution without a bundler

<script type="importmap">
{
  "imports": {
    "@td/dom": "/assets/dom-8c1f4a7.js"
  }
}
</script>
<script type="module" src="/assets/main-9b2d0e6.js"></script>
the import map is generated by the same script that
writes the hashes, which is the coupling that makes
this work — a hand-maintained map is a second place
the hashes have to be right.

what it does not solve: a deep dependency graph. each
module is discovered only after its importer has been
parsed, so a chain of four is four round trips.

ours is flat: main imports dom, and that is the whole
graph.

What we gave up

  tree shaking       nothing to shake. no runtime
                     dependencies.
  HMR                replaced by a watcher and a page
                     reload. the difference on a site
                     with no client-side state is
                     nothing.
  CSS handling       the stylesheet is built
                     separately and always was.
  the plugin
    ecosystem        genuinely lost. if a requirement
                     arrives that has a plugin, this
                     decision reverses.

and the honest one: the ability to add a dependency
without thinking about it, which is a loss and is
also the point.

The watcher, which is four lines

esbuild src/*.ts --watch --outdir=dist/assets 
  --format=esm --target=es2022 &

while inotifywait -qre modify src/ >/dev/null; do
  ./bin/build-manifest
done

Verifying it worked

$ wc -c dist/assets/*.js | tail -1
  18104 total          # was 18412

$ npm ls --all --omit=dev 2>/dev/null | wc -l
  1                    # esbuild

$ time ./bin/build
real    0m0.088s       # was 1.104s

$ npx playwright test
  22 passed            # unchanged

$ git diff --stat
 package.json      |   9 +---
 package-lock.json | 4,102 +---------
 vite.config.ts    |  22 ------
 bin/build         |  12 ++++

Four thousand lines of lock file removed is the measurement that matters, because it is four thousand lines of supply chain. The build being twelve times faster is a consequence of doing twelve times less and is not a reason to do this.

What this costs

A twelve-line script nobody else recognises, against a configuration file nobody read. That is a real trade — a new person can look up what a bundler option does and cannot look up what our script does, and the mitigation is a comment block that will go stale.

It also reverses the moment a requirement arrives that a plugin solves. Adding a framework, a CSS-in-JS library or anything with a build-time transform means putting the bundler back, and the honest position is that this is right for a site with four modules and no dependencies and would be wrong the week either of those changes.