Harish Kumar
war-storyarchitect-mindsetgap

Killing the Build-Publish Loop: Hot Reloading for Internal npm Packages

How I killed a 2–3 minute build-publish loop for internal npm packages with a 50-line webpack plugin — and why the solution required understanding webpack's file watcher, not its HMR APIs.

July 21, 20257 min

The loop was this: edit a component in cap-ui-library, run npm run build, publish a prerelease to npm, bump the version in the consumer app, restart the dev server, wait. Then see if it worked.

If it didn't — and it usually didn't on the first try — you did it again.

Two to three minutes per iteration. For a UI component. The kind of work where you're tweaking padding and wondering why you became an engineer.


What internal package development actually looked like

Capillary's frontend is split across shared libraries and consuming applications. cap-ui-library holds the component system. arya-common-utils handles shared utilities. Consumer apps like cap-campaigns-v2 pull from these as npm dependencies.

This is a reasonable architecture. The problem is what it does to the inner development loop.

When you're building app code, webpack's HMR just works. Save a file, see the change in under a second. The feedback loop is tight. That tightness is load-bearing — it's what lets you iterate quickly, catch visual regressions early, stay in the flow.

When you're building a shared library that another app consumes, none of that applies. The library is a compiled artifact to the consumer app. Webpack doesn't know where the source came from. It just sees a node_modules package. There's no wire to pull on for hot reload.

So you publish. And wait. And do it again.

Three developers maintaining cap-ui-library meant this wasn't a personal inconvenience — it was a team-wide tax on iteration speed.


The constraints

Three developers on a shared library, no monorepo, no infra budget for tooling changes. Any solution had to work within the existing webpack-based consumer apps without requiring those apps to restructure. It couldn't add a new infrastructure dependency — no additional servers, no Docker containers, nothing that requires a separate setup step per dev. And it had to be transparent: library changes should appear in the consuming app automatically, the same way app-level changes already do with HMR. The threshold was simple: make it fast enough that you stay in the file.

The constraint that ruled out the obvious approaches

The obvious answer is: use webpack's HMR APIs to watch the library source directly.

The problem is that webpack's HMR internals are private. The APIs that fire a hot update are not part of the public interface. They're implementation details that webpack reserves the right to change between versions. Calling them directly works until it doesn't, and when it breaks, it breaks silently and confusingly in a dependency deep in your toolchain.

I evaluated five approaches against this constraint:

  • Monorepo workspace linking — works well for monorepos, but we're not a monorepo. Migration cost wasn't zero, and it doesn't give you HMR for library source files directly.
  • Module Federation — runtime code sharing, not development iteration. High config complexity for a problem that's fundamentally about local dev ergonomics.
  • Dev server proxy — gives you HMR for app code only. Library source still needs to be built.
  • Local Nginx reverse proxy — interesting, but adds infrastructure to a tooling problem.
  • Docker Compose orchestration — serious overkill. More moving parts, not fewer.

The POC I'd run two weeks earlier had a different shape. It scored 4.4 on the weighted evaluation versus 3.2 and 3.4 for the next-best alternatives. The shape of that score mattered: it was highest on "dev feedback loop" and "setup complexity" — the two things that actually affect day-to-day work.

That POC became the thing I was now productionizing.


The insight: you don't need to call HMR APIs

The cleaner approach came from thinking about what webpack's file watcher actually responds to.

Webpack watches your source files using their modification timestamps. When a file's mtime changes, webpack detects it, recompiles, and sends an HMR update to the browser. This is the whole mechanism. It's not magic — it's fs.stat on a loop.

If you want webpack to think a file changed, you can just make the file's mtime change. Node's standard library has fs.utimesSync for exactly this. Touch the entry file. Webpack sees the timestamp update. Webpack recompiles. HMR fires.

No private APIs. No internal hooks. Just the stable Node.js fs module and webpack's ordinary file watching behavior.

const onFileChange = () => {
  if (this.entryFile) {
    const now = new Date();
    fs.utimesSync(this.entryFile, now, now);
  }
};

The LibraryWatcherPlugin hooks into compiler.hooks.watchRun to start watching and compiler.hooks.watchClose to stop. These are stable webpack lifecycle hooks — documented, versioned, not going anywhere. When the watcher detects a change in the library source directory, it calls onFileChange. Webpack does the rest.


The Chokidar configuration and why awaitWriteFinish matters

The watcher is Chokidar. Specifically:

watchOptions: {
  ignored: /(^|[\/\\])\../,
  persistent: true,
  ignoreInitial: true,
  awaitWriteFinish: {
    stabilityThreshold: 200,
    pollInterval: 100
  }
}

awaitWriteFinish is the detail that makes this reliable in practice. Without it, Chokidar emits a change event as soon as it sees any write activity — which means it fires on partially-written files. A build tool writing a bundle writes multiple chunks. Without awaitWriteFinish, you'd trigger a reload mid-write, with a malformed file.

stabilityThreshold: 200 tells Chokidar: don't emit until the file hasn't changed for 200ms. That's enough time for any reasonable write operation to finish. pollInterval: 100 is how often it checks. The net result is that you don't reload on garbage — you reload on stable output.

ignoreInitial: true prevents a spurious reload when the watcher first scans the directory on startup. Without it, every file in the watched path looks like a new change when the watcher initializes.

There's also a production guard baked into the plugin:

if (compiler.options.mode !== 'development' || !this.options.libs.length) return;

The whole plugin is a no-op in production builds. This was a requirement, not an afterthought — shipping watcher code into production bundles is the kind of thing that creates mysterious issues six months later.


Three days

The first commit landed on July 21. v1.0.0 was on npmjs.org by July 22. The integration guide was committed on July 24.

Three days from nothing to a published, documented npm package.

The reason it moved that fast is that the hard thinking was already done. The POC had validated the core mechanism. The alternatives evaluation had ruled out the approaches that would have required more infrastructure or webpack internals. By the time I sat down to write production code, the design was settled.

The POC wasn't a detour. It was load-bearing. The three days were implementation, not exploration — and that's the whole point of doing the thinking separately.

The package is @capillarytech/cap-ui-dev-tools, published under the @capillarytech scope. The plugin is at the ./webpack subpath export. Integration for cap-campaigns-v2 took a config change and an npm install.


What changed for the team

Changes in cap-ui-library now show up in consuming apps in under 5 seconds. No manual build. No version bump. No restart.

That's not a small thing. The 2-3 minute loop wasn't just slow — it broke context. You'd make a change, start the publish cycle, context-switch to something else, come back, find it didn't work, repeat. The cognitive overhead of managing that loop was at least as expensive as the time itself.

Under 5 seconds means you stay in the file. You see the result. You make the next change. The feedback loop is tight enough to actually iterate.

The architecture also means this works for any shared library, not just cap-ui-library. Point the plugin at any source directory, configure it in the consumer app's webpack config, and it watches. We can extend this to arya-common-utils or any other package without changing the plugin.


What we gave up

The mtime approach is clever, but it's non-obvious to anyone who inherits it. A future maintainer who sees fs.utimesSync being called on a webpack entry file will need to read the plugin carefully to understand what it's doing and why. This is a maintenance cost we're accepting.

The plugin also only works for webpack-based consumers. If a team moves to Vite for their consumer apps, this plugin doesn't apply — they'd need to build the equivalent for Vite's dev server. We're webpack-only right now, and this solution is coupled to that choice.

The stabilityThreshold: 200ms bakes in a floor. On slower machines or with large library bundles, 200ms might not be enough for the write to stabilize — you'd see occasional reloads on partially-written output. It hasn't been a problem in practice, but it's a setting that might need tuning in other environments.

The mental model that's actually useful here

The standard advice for dev tooling is: prefer existing solutions over custom ones. Maintenance cost, onboarding, edge cases.

That advice is right most of the time. But it assumes existing solutions exist. They didn't here — not for this specific shape of the problem. A Chokidar-plus-utimesSync webpack plugin for internal package hot reload isn't something you find in the npm registry.

This isn't an argument for building custom tooling. It's an argument for understanding the mechanism before reaching for a workaround. Webpack's file watcher responds to mtime changes — that's knowable from reading the source. Once you know that, the solution is obvious. fs.utimesSync is two lines. The hard part was knowing that was the right two lines to write.

The alternatives evaluation scored 5 approaches against 6 criteria — dev feedback loop, setup complexity, maintenance cost, webpack version safety, infrastructure footprint, and team onboarding. The winning approach wasn't the most sophisticated. It was the one that worked with the grain of the existing tooling instead of fighting it.

Three days to ship. That ratio — weeks of thinking, days of building — is the one I'm trying to hold onto.

The question that's forming as this lands: when did the 2-3 minute loop become normal? The instinct during the day was to optimize the habit — batch the changes, manage the publish cycle, minimize context switches. What the plugin reveals is that the loop was a design gap, not an inherent cost. Worth asking that question earlier next time — before the workaround becomes the process.