Maximally minimal build process

January 4th, 2026. Tagged: JavaScript, performance


In my previous post I described how I set up sightread.org with no build process and modern JavaScript. The goal was raw ES modules, no transpilation, no bundling, just <script type="module"> and we're off.

sightread.org screenshot

That worked great for a minute but I wanted just one more thing: long-lived immutable JS resources that can be cached by the browser. I mean, I dig web performance. Which required me to take a breath and hack on a small build.js.

Why Build At All?

The development setup is delightfully simple:

<script src="libs/abcjs-basic-min.js" defer></script>
<script type="module">
  import { init } from './app.js';
  import './bookmarkable.js';
  import './rossini.js';
  // ... more imports for parallelization
  init();
</script>

This works fine, the browser's preload scanner discovers all the imports in parallel (thanks to the defer on the library script, see the previous post). No bundler needed.

But here's the problem: I want aggressive caching. Set Cache-Control: max-age=31536000 and forget about it. And so hashes in filenames rear their hash-y heads: like app.a1b2c3d4.js instead of app.js. When the code changes, the hash changes, the URL changes, caches are busted like... a DMB album.

Why Not Import Maps?

My first thought was import maps. Define a JSON mapping in the index HTML:

<script type="importmap">
{
  "imports": {
    "./app": "./dist/app.a1b2c3d4.js"
  }
}
</script>

That was it! The browser handles things when a JS module does import 'app'. No need to touch the imports themselves.

One problem: Safari 16.4 is required for import maps. My minimum target is Safari 15.3 (the last version available on iPhone 8). So import maps are out.

The No-package.json Philosophy

I refuse to have a package.json. Why?

  1. No node_modules folder weighing as much as a .wav-full of a DMB album for a dependency-free-ish project such as this one
  2. No version lock files to maintain
  3. No "dependency of a dependency" security alerts, who among us enjoys "dependabot" emails

Every tool I use (since we're building, let's throw minification in the mix!) runs via npx. The first build takes a few seconds longer to download them. After that, they're cached.

The Build Script

My entire build process is a single build.js file, about 200 lines of vanilla Node.js. No Webpack, no Vite, no Rollup. Here's what it does:

1. Run Tests First

execSync('node -e "import(\'./test.js\').then(m => m.test())"');

If a test fails, the build is busted... like a DMB album. Of course.

2. Backup and Clean

The current index.html is backed up with a timestamp. The dist/ directory is cleared.

3. Minify CSS

execSync('npx lightningcss-cli --minify --targets "safari 15" dist/temp.css -o dist/styles.min.css');

Lightning CSS is fast and handles the Safari 15 target automatically. All <style> blocks are extracted, combined, minified, and inlined back. Yup, all (vanilla) CSS lives inside the index HTML.

4. Minify JavaScript with Content Hashes

execSync(`npx esbuild ${filePath} --minify --outfile=${outputPath}`);
const content = readFileSync(outputPath, 'utf8');
const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
const hashedName = file.replace('.js', `.${hash}.js`);

esbuild minifies each file. I calculate a SHA-256 hash of the content, take the first 8 characters, and rename the file. app.js becomes app.06763e5a.js.

BTW, I was using swc for JS minification before, but it suddenly decided to require local install and this goes against the node_modules-free area rule. Out with it, bienvenue ESBuild!

5. Rewrite Imports

Since I can't use import maps, I have to rewrite the imports in the minified files themselves:

// In each minified JS file, replace:
// from"./music-lib.js" ? from"./music-lib.bb657cf8.js"

This is the ugly part. I'm essentially doing what import maps would do for free, but at build time instead of runtime. It's not too bad though and it doesn't need a parser or anything. Just string replace ./src/ parts.

6. Update HTML

The main HTML file gets its imports updated to point to the hashed files:

Object.keys(hashMap).forEach(originalPath => {
  const hashedPath = hashMap[originalPath];
  html = html.replace(originalPath, hashedPath);
});

7. Transform Service Worker

Oh yeah, I added a service worker after a long flight. I need this app working offline!

The service worker needs to know about the new hashed filenames for its cache list. The cache version also gets the build timestamp:

swContent = swContent.replace(/sightread-v1/, `sightread-${timestamp}`);

8. Minify HTML

Finally, html-minifier-terser removes whitespace and comments from the HTML.

The Output

Building...

Running tests...
? All 9 test suites passed (99 examples)

Cleaning dist directory...
Backing up index.html...
Minifying CSS...
Minifying JavaScript...
  ? app.js ? app.06763e5a.js
  ? bookmarkable.js ? bookmarkable.0708cbe8.js
  ? exercise.js ? exercise.91ef49a7.js
  ...
Updating JavaScript imports...
Updating HTML imports...
Transforming Service Worker...
  ? sw.js (cache: sightread-2025-12-24T17-02-07)
Minifying HTML...

? Build complete!
Backup: backups/2025-12-24T17-02-07.html
Output: index.2025-12-24T17-02-07.html
Assets: dist/

The result: individual hashed files that can be cached forever, with a timestamped HTML file ready to deploy. Where deploy means FTP. Old school.

Trade-offs

What I gave up:

  • Tree-shaking. I have one dependency, the rest is my bespoke code, so not much to win, I think
  • Code splitting. I do this manually with dynamic imports. The files are split based on their purpose.
  • Source maps. Hmm, maybe these fellas can be added, shall the need arise.
  • Hot module replacement. I just refresh the page.

What I gained:

  • A build process I can understand
  • No dependency hell
  • Complete control over every step

Conclusion

The JavaScript ecosystem loves complexity. But for a small project like sightread.org, a 200-line build script beats a 200MB node_modules folder every time.

And that's the other thing. The project is small partially because of the lack of dependencies. It's a web app alright, quite dynamic and fully client-side, exercise generation logic and all. I could've reached for React maybe. But the result is an app under 15K (minified and zipped). You cannot load React itself in under 55K (or so, I think). I have big plans about this project, so let's see how this no-package, no-React approach will work longer term.

Comments? Find me on BlueSky, Mastodon, LinkedIn, Threads, Twitter