Using esbuild Context Watch Mode for Incremental Rebuilds

You want sub-100ms incremental rebuilds and a dev server, without re-spawning a full build() on every keystroke. The answer is esbuild.context() plus ctx.watch() and ctx.serve() — the API that replaced the deprecated watch: true and incremental: true flags. This guide builds a complete watch-and-serve script with rebuild callbacks and graceful disposal. It sits under the esbuild API and CLI for Rapid Builds overview, which contrasts context() against the one-shot build() and stateless transform() entry points.

A context holds the parsed module graph in memory. watch() re-resolves only the files that changed; serve() exposes the in-memory outputs over HTTP without writing to disk. The cost you must not skip is dispose() — a live context keeps the Go subprocess and its file-system watchers alive, and forgetting it leaks handles across reloads.

esbuild context watch and serve lifecycle A context is created once, then watch and serve reuse its in-memory graph until dispose tears it down. context() parse graph once hold in memory ctx.watch() file change incremental rebuild ctx.serve() HTTP dev server serves from memory rebuild callback plugin onEnd hook Legacy watch: true / incremental: true are removed in 0.17+ use context() + watch(); build({ watch }) throws on modern esbuild SIGINT / SIGTERM → await ctx.dispose() frees FSWatcher handles and exits the Go subprocess cleanly
Figure: one context feeds both watch and serve; dispose is the only clean way to release its watchers and subprocess.

Prerequisites & reproducible setup

# esbuild 0.25.x, Node 20+
mkdir esb-watch && cd esb-watch
npm init -y
npm pkg set type=module
npm install --save-dev esbuild@0.25
mkdir -p src && printf "console.log('hello');\n" > src/index.ts

You need esbuild 0.25.x and Node 20+. The context() API arrived in 0.18.0 and serve() in 0.17.0; the legacy watch: true and incremental: true build options were removed in 0.17, so they are not available on any version this guide targets.

Diagnosis workflow: confirm you need a context, not a build loop

  1. Check for the legacy pattern. If your script passes watch: true to esbuild.build(), modern esbuild throws No loader is configured for ... / Invalid option: "watch". That is the signal to migrate.
  2. Measure your current rebuild cost. Wrapping build() in a file-watcher re-parses the entire graph each save. Time it with console.time('build'); on a few-hundred-file project a fresh build() is often 100–250ms versus 15–40ms for a context rebuild.
  3. Decide watch vs serve. Use watch() when another process (a framework, a test runner) consumes the on-disk output. Use serve() when you want esbuild itself to host the assets over HTTP from memory. You can run both on one context.

The complete annotated solution

A complete, runnable dev.mjs. It creates one context, attaches a rebuild-reporting plugin, starts both watch and serve, and disposes cleanly on Ctrl+C.

// esbuild 0.25.x, Node 20+
import * as esbuild from 'esbuild';

// 1. A plugin onEnd hook fires after every (re)build — the place to log
//    timing, push a live-reload event, or run a follow-up step.
const rebuildReporter = {
  name: 'rebuild-reporter',
  setup(build) {
    let started = 0;
    build.onStart(() => {
      started = performance.now();
    });
    build.onEnd((result) => {
      const ms = (performance.now() - started).toFixed(1);
      const errors = result.errors.length;
      console.log(
        errors
          ? `Rebuild failed with ${errors} error(s) in ${ms}ms`
          : `Rebuilt in ${ms}ms`
      );
    });
  },
};

// 2. Create the context ONCE. It parses the graph and keeps it in memory.
const ctx = await esbuild.context({
  entryPoints: ['src/index.ts'],
  bundle: true,
  format: 'esm',
  outdir: 'dist',
  sourcemap: 'inline',
  logLevel: 'silent', // the plugin handles reporting
  plugins: [rebuildReporter],
});

// 3. watch() registers the file-system watcher; subsequent saves trigger
//    incremental rebuilds that reuse the in-memory graph.
await ctx.watch();
console.log('Watching for changes...');

// 4. serve() hosts the in-memory outputs over HTTP. It does not write to
//    disk; it serves the freshest build for each request.
const { host, port } = await ctx.serve({
  servedir: 'dist',
  port: 8000,
});
console.log(`Dev server: http://${host}:${port}`);

// 5. Graceful teardown. Without dispose(), the Go subprocess and its
//    FSWatcher handles outlive the script and leak across restarts.
const shutdown = async () => {
  console.log('\nDisposing context...');
  await ctx.dispose();
  process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

Run it with node dev.mjs. Edit src/index.ts and the plugin logs a Rebuilt in Nms line within milliseconds; open http://localhost:8000 to hit the served output. Press Ctrl+C and the dispose hook tears everything down.

Verification

  • Incremental rebuilds fire: after node dev.mjs, save src/index.ts and confirm a Rebuilt in <ms>ms line appears. The millisecond figure should be far below the cold-build time you measured in the diagnosis step.
  • Server serves fresh output: curl -s http://localhost:8000/index.js | head returns the just-built code; edit the source and re-curl to see the change without restarting.
  • Clean disposal: press Ctrl+C and confirm the process exits with code 0 and no lingering Node process. Verify no leaked watchers with process.getActiveResourcesInfo() if you wrap the shutdown in a diagnostic build.

Gotchas & edge cases

  • watch() and serve() return immediately. They register the watcher/server and resolve; they do not block. Your script stays alive because the watcher and server hold the event loop open — do not add a manual await loop.
  • One dispose() per context. Calling ctx.rebuild() or ctx.watch() after dispose() throws. If you support config hot-reload, dispose the old context and create a brand-new one.
  • serve() does not bundle on a schedule. It rebuilds on request and on file change; a request mid-edit gets the latest successful build, not a partial one. Errors surface in the onEnd result, so report them or the server silently keeps serving stale output.
  • Migrating from watch: true. Replace esbuild.build({ ..., watch: true }) with const ctx = await esbuild.context({ ... }); await ctx.watch();. The old onRebuild callback moves into a plugin onEnd hook, as shown above. For the broader bundling-stage flags you will combine with this loop, see Reducing esbuild bundle size with minify and tree-shaking.