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.104sA 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.