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.
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
- Check for the legacy pattern. If your script passes
watch: truetoesbuild.build(), modern esbuild throwsNo loader is configured for ... / Invalid option: "watch". That is the signal to migrate. - Measure your current rebuild cost. Wrapping
build()in a file-watcher re-parses the entire graph each save. Time it withconsole.time('build'); on a few-hundred-file project a freshbuild()is often 100–250ms versus 15–40ms for a context rebuild. - Decide watch vs serve. Use
watch()when another process (a framework, a test runner) consumes the on-disk output. Useserve()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, savesrc/index.tsand confirm aRebuilt in <ms>msline 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 | headreturns 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
0and no lingering Node process. Verify no leaked watchers withprocess.getActiveResourcesInfo()if you wrap the shutdown in a diagnostic build.
Gotchas & edge cases
watch()andserve()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 manualawaitloop.- One
dispose()per context. Callingctx.rebuild()orctx.watch()afterdispose()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 theonEndresult, so report them or the server silently keeps serving stale output.- Migrating from
watch: true. Replaceesbuild.build({ ..., watch: true })withconst ctx = await esbuild.context({ ... }); await ctx.watch();. The oldonRebuildcallback moves into a pluginonEndhook, 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.
Related
- esbuild API and CLI for Rapid Builds — how context differs from build and transform.
- Reducing esbuild bundle size with minify and tree-shaking — the production flags to combine with this dev loop.
- Using esbuild transform API for TypeScript stripping — single-file conversion when you do not need a full context.
- Turbopack Incremental Compilation — how Rust-based invalidation compares to esbuild’s in-memory graph reuse.