engineering · by Sherif Butt · 2026-04-30 · 9 min read · v0.8.7

The 3,700 bytes that broke our CI for two weeks.

Why we ripped esbuild out of the build pipeline and replaced it with plain tsc. A story about platform-specific output, deterministic builds, and dist diffs you can't see with your eyes.

BUILD COMPARISON — SAME INPUT, DIFFERENT BYTES darwin-arm64 DEV LAPTOP linux-x64 CI RUNNER Δ DELTA ~3,700 B esbuild output 2.5 MB · server-main.js esbuild output 2.5 MB · server-main.js same source · same lockfile · same esbuild version
FIG. 01 · esbuild build comparison — bone outlines = matching bytes; amber = divergence. 2026-04-30

Two weeks before v0.8.7 shipped, we added a dist-check workflow to the repo. The job was simple: rebuild the MCP server from source on Linux, diff the result against the committed mcp-server/dist/ directory, and fail the build if they didn't match. The intent was to catch the case where someone updated the source but forgot to run npm run build before committing.

The check went red on the very first push. We rebuilt, recommitted, pushed again. Still red. We diffed locally on the developer machine — a darwin-arm64 MacBook — against what CI had built on ubuntu-latest. The diffs were tiny. A few hundred bytes here. A whitespace shift there. About 3,700 bytes total across a few files, on inputs that were byte-identical: same source, same package-lock.json, same esbuild version pinned in devDependencies.

For two weeks every PR went red. We ignored the check, then considered deleting it. Eventually we did the thing we should have done on day one: read what the diff actually was.

What esbuild was doing.

esbuild is a fantastic bundler. It's fast, it has good defaults, it handles TypeScript natively, it tree-shakes aggressively. We had been using it to compile mcp-server/src/**/*.ts into a small set of bundled .js files in dist/. Around 4 megafiles total, with the runtime native deps (@imgly/background-removal-node, sharp, onnxruntime-node) marked --external so they'd resolve from node_modules at runtime.

The bundled output was efficient and self-contained. It was also, we discovered, not byte-identical across operating systems. esbuild does a small amount of platform-specific work during code generation. Tree-shaking decisions can depend on dependency-graph traversal order, which depends on filesystem enumeration order, which is OS-specific. Source-map encoding has minor variants. None of this affects runtime behavior — the output is functionally identical — but the bytes differ.

For a project with a "the dist in git matches what CI builds" check, "functionally identical" is not the same as "byte-identical." The check failed for a real reason: we had no guarantee that the dist committed by a Mac developer would match what an Ubuntu CI runner produced.

The first instinct was wrong.

The obvious fix is to move the build into CI. Don't commit the dist; let CI build it on every release. We considered this and rejected it. The plugin gets fetched by Claude Code's marketplace mechanism from the GitHub raw URL, and the dist has to be in the repo at install time. We could publish a separate release artifact, but that adds complexity for a small project.

The right fix isn't a smarter check. It's a builder whose output doesn't depend on which laptop ran it. — commit message, v0.8.7

What we wanted was a build tool whose output is deterministic across operating systems on identical inputs. That ruled out bundlers in general, since most of them make platform-influenced decisions during code generation. It pointed at the most basic thing on the shelf: tsc.

What we lost, what we gained.

Switching from esbuild to tsc meant giving up bundling entirely. tsc compiles each .ts file to one .js file. No tree-shaking, no inlining, no minification. Our dist went from 4 bundled megafiles to ~50 small files mirroring the source tree.

Counterintuitively, the total dist size went down. Way down.

server-main.js
2.5 MB 39 KB
cli-main.js
1.9 MB 22 KB
os-cross diff
~3,700 B 0 B

The reason: esbuild was inlining the dependency tree into the bundle, even though most of those deps were already being shipped via node_modules at install time (the bootstrap shim runs npm ci --omit=dev on first launch since v0.8.3). The bundle was duplicating code that the runtime already had on disk.

tsc just emits import {...} from '@imgly/...' verbatim. Node's runtime resolution finds the dep in node_modules at first call, exactly as it did before. No behavior change. No size penalty. The bundle was solving a problem that didn't exist for our deployment shape.

The postbuild step.

tsc doesn't do everything esbuild did. We needed three small things that the bundler had handled automatically:

  • Copy pricing.json to dist/pricing/ so the compiled load.js can find it via readFileSync.
  • Prepend #!/usr/bin/env node shebangs to the entry files so they're directly executable.
  • chmod +x the entry files.

All three got shoved into a tiny scripts/postbuild.mjs:

// scripts/postbuild.mjs — runs after `tsc`
import { readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync } from 'node:fs';

// 1. copy pricing.json next to its loader
mkdirSync('dist/pricing', { recursive: true });
copyFileSync('src/pricing/pricing.json', 'dist/pricing/pricing.json');

// 2. + 3. prepend shebangs to entry points and chmod
for (const entry of ['dist/server.js', 'dist/cli.js']) {
  const body = readFileSync(entry, 'utf8');
  if (!body.startsWith('#!')) {
    writeFileSync(entry, '#!/usr/bin/env node\n' + body);
  }
  chmodSync(entry, 0o755);
}

Cross-platform. Node fs API only. No npm dep. The whole thing is shorter than this paragraph.

One more thing: JSON imports.

The pricing module had been importing its JSON via the modern syntax:

import pricing from './pricing.json' with { type: 'json' };

Import attributes (the with clause) became stable in Node 22. esbuild handled this fine because it inlined the JSON directly into the bundle. tsc compiles the import statement through to the output, and the result needs to actually run on whatever Node version users have. Our minimum supported version is Node 18, where import attributes are still flagged.

We swapped to readFileSync + JSON.parse — universal across every Node version we'd ever target — and called it done. The cost is a few microseconds at startup. The benefit is that nothing breaks for users on the LTS line.

Lessons.

Read the diff before changing tools

We lost two weeks because we kept retrying the build instead of asking what specifically is different. The diff was small, readable, and would have pointed at the answer in fifteen minutes. "It's flaky" is almost never true. There's a cause, and the diff is usually showing you part of it.

Bundling is a deployment choice, not a default

We had reached for esbuild because that's what modern TypeScript projects do. But our deployment shape — node modules already on disk via a bootstrap install — meant the bundle was duplicating work the runtime was already doing. The smaller, simpler tool produced a smaller, simpler artifact. Defaults are defaults because they fit a common case, not your case.

Determinism is a property of the build pipeline, not a flag

You don't get reproducible builds by adding --reproducible. You get them by choosing tools that don't make platform-influenced decisions, and by treating the output bytes as a contract. The whole pipeline has to cooperate. tsc isn't reproducible because it tries hard; it's reproducible because the work it does is straightforward enough that the same inputs reliably produce the same outputs.

Postbuild scripts are fine

A 12-line scripts/postbuild.mjs using only Node built-ins is worth more than a plugin ecosystem. There was a moment, mid-migration, where we considered adding a build-tool plugin to handle the shebang and the asset copy. We didn't. The script is shorter than the plugin's README would have been.


The dist-check has been green on every push since v0.8.7 landed. The changelog entry has the one-line summary. The full migration was a single commit. The total time spent on it — once we read the diff — was an afternoon.

Read the diff.

all posts ← back to the blog index older post → Seven hotfixes to ship a Claude Code plugin.