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.
The problem this solves is that a naive dev loop calls build() inside a file watcher. Each save spawns a fresh compilation: esbuild re-reads every entry, re-resolves every import, re-parses every module, and re-runs the whole plugin pipeline before it can emit. On a small project that is 100–250ms of wasted work per keystroke; on a large one it climbs past half a second, which is long enough to break the edit-save-see-it feedback loop that makes esbuild worth using in the first place. Nothing about that work actually changed except the one file you touched, yet you pay for all of it every time.
A context holds the parsed module graph in memory between rebuilds. watch() re-resolves only the files that changed and the modules that import them, then patches the existing graph rather than rebuilding it from scratch; serve() exposes the in-memory outputs over HTTP without writing to disk. The context is the unit of caching — it is what lets esbuild answer “what changed?” instead of “build everything.” In a typical build pipeline it slots in as the development-time front end: the same options you would pass to a production build() go into context(), and only the dev-only concerns (watch, serve, live reload, timing logs) live on top. That symmetry means your dev and production configs cannot drift, because they are the same object minus a few dev flags.
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. In a long-running dev session that starts and stops contexts (config hot-reload, test harnesses, a task runner that restarts the build), an undisposed context leaves an orphaned esbuild child process holding OS-level FSWatcher handles. After a dozen restarts you have a dozen zombie subprocesses, file descriptors climbing toward the per-process limit, and inotify watches on Linux quietly exhausting fs.inotify.max_user_watches. The symptom is a dev server that gets slower and eventually throws ENOSPC: System limit for number of file watchers reached — and the root cause is a missing await ctx.dispose().
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. This history matters because most stale tutorials and Stack Overflow answers still show build({ watch: true }), and pasting that into a modern install produces a hard error rather than a deprecation warning — the option no longer exists, so esbuild rejects it as an unknown key. If you inherit a script written against esbuild 0.14–0.16, treat the migration as mandatory rather than optional; there is no compatibility shim.
Pin the version explicitly (esbuild@0.25, not ^0.25) for any script that measures rebuild timings, because esbuild’s minor releases occasionally change how the incremental cache is keyed and a floating range can move your baseline out from under you between installs. The type=module field is what lets the dev script use top-level await and the import * as esbuild syntax below; without it Node treats dev.mjs as CommonJS unless the file uses the .mjs extension, which is why the runnable script in this guide is named dev.mjs regardless of the package.json setting.
watch: true is gone — the modern loop is one context() plus watch()/serve().Diagnosis workflow: confirm you need a context, not a build loop
build() loop re-parses the whole graph; a context reuses it — often a 5–8× rebuild speedup.- Check for the legacy pattern. If your script passes
watch: truetoesbuild.build(), modern esbuild throwsInvalid option: "watch"(or a related loader error when the malformed options object confuses resolution). The symptom is a build that used to work failing immediately on the first call after an esbuild upgrade. The root cause is the removed option; the fix is to move the build options intoesbuild.context()and callwatch()on the returned handle. Confirm the migration by checking that the process now stays alive after the first build instead of exiting — a context with an active watcher does not return control to the top level the way a one-shotbuild()does. - Measure your current rebuild cost. Wrapping
build()in a file-watcher re-parses the entire graph each save. Time it withconsole.time('build')around the call and save the same file ten times to get a stable median; on a few-hundred-file project a freshbuild()is often 100–250ms versus 15–40ms for a context rebuild. If your current rebuilds are already under ~20ms because the project is tiny, a context buys you little and the extra lifecycle code (dispose handling, signal wiring) is not worth it — the win scales with graph size, since the fixed cost you avoid is re-parsing files that did not change. Measure before you migrate so you can prove the speedup rather than assume it. - Decide watch vs serve. Use
watch()when another process (a framework, a test runner, a downstream bundler step) consumes the on-disk output and needs files written as they change. Useserve()when you want esbuild itself to host the assets over HTTP from memory, which skips the disk write entirely and is faster for a browser-only dev loop. The two are not exclusive: you can run both on one context, and a common setup iswatch()for a sidecar tool plusserve()for the browser. Getting this choice wrong is cheap to correct — it is one or two extra lines — but choosingserve()when a separate process is readingdist/from disk will leave that process staring at stale files, becauseserve()deliberately never writes them.
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.
dispose() on shutdown.// 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.
The ordering in that script is deliberate. The context is created first because both watch() and serve() are methods on the handle it returns — there is nothing to watch or serve until the graph exists. The plugin is registered inside context() rather than added later, because plugins are fixed at context-creation time and cannot be attached to a live context. Watch is started before serve so that the very first save is already being observed by the time the server is reachable; reversing them opens a small window where a request could arrive before the watcher is armed. The signal handlers are registered last, after everything they need to tear down exists, so a Ctrl+C during startup cannot call dispose() on a half-built context.
How incremental rebuild works under the hood
When you call context(), esbuild’s Go core parses each entry point, walks its imports, resolves and loads every reachable module, and keeps the resulting graph — the parsed ASTs, the resolved module identities, and the plugin resolutions — resident in the subprocess. A rebuild is not a re-run of that whole pipeline. On a file-change event the watcher tells the core which paths changed; the core marks those modules dirty, re-reads and re-parses only them, and then re-links. Modules that did not change are reused from the in-memory graph without touching the disk or the parser. This is why rebuild time tracks the size of the changed set rather than the size of the project: editing one leaf module re-parses one file, and the rest of the graph is patched, not rebuilt.
Resolution is the part people underestimate. esbuild caches the outcome of each import specifier — the absolute path it resolved to, and which plugin (if any) claimed it. On a rebuild, unchanged import edges reuse the cached resolution and never re-enter the resolver or your plugin’s onResolve hooks. A change only re-triggers resolution for the edges out of the modules that were actually re-parsed. The consequence for plugin authors: an onResolve or onLoad hook that reads external state (an environment variable, a file it does not declare as a watch path) will not re-run when that external state changes, because esbuild has no edge telling it to invalidate. If your plugin depends on a file, return it in the watchFiles array from onLoad so the context knows to invalidate on its change; otherwise the stale cached result silently survives the rebuild.
The watcher itself is polling-based on most platforms rather than a pure native inotify/FSEvents subscription. esbuild checks the modification times of the files it has loaded on a short interval, which is robust across network mounts and editors that write via rename-and-replace, but it means a change is detected on the next poll tick rather than instantaneously — the sub-100ms rebuild figure includes that detection latency, not just the compile. It also means esbuild only watches files it actually loaded; a file that is not part of the graph (a config file read by your own script, a template referenced only as a string) will not trigger a rebuild unless you declare it.
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. If it is roughly equal to the cold build, the context is not being reused — the usual cause is code that recreates the context on each change instead of reusing the one handle. If no line appears at all, the file you edited is not in the graph, or the plugin’sonEndis being shadowed by alogLevelthat is notsilent. - 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. If the second curl returns the old code, either the rebuild failed (check for an error line from the reporter) or you are curling a path thatservediris not mapping —serve()serves from the in-memory output keyed by the URL path, so/index.jsmust match the emitted filename underoutdir. - 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; beforedispose()you will see the esbuild subprocess and the server socket in the list, and after it they should be gone. On Linux you can cross-check by watching the count under/proc/sys/fs/inotifyor simply confirmingpgrep -f esbuildreturns nothing after the script exits.
For a browser dev loop you usually want the page to reload itself when a rebuild lands, not for you to hit refresh. esbuild does not ship a live-reload client, but serve() exposes exactly enough to build one: an EventSource endpoint at /esbuild that emits a change event after every successful rebuild. Inject a tiny client into your HTML and the browser reloads on its own.
// esbuild 0.25.x — paste into your served HTML, e.g. dist/index.html
// Subscribes to esbuild's built-in rebuild event stream and reloads.
new EventSource('/esbuild').addEventListener('change', () => {
location.reload();
});
This works only because serve() is running; the /esbuild endpoint does not exist under a plain build() or a bare watch(). The event fires after onEnd, so a failed rebuild does not reload the page onto broken output — the browser keeps showing the last good build until a rebuild succeeds.
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. Confirm the migration by checking two things: that the process no longer exits after the first build, and that your rebuild logging still fires — the most common migration bug is porting the build options but forgetting to moveonRebuild, which leaves you watching silently with no idea whether saves are landing. For the broader bundling-stage flags you will combine with this loop, see Reducing esbuild bundle size with minify and tree-shaking.
When not to use a context
A context earns its keep only when the same graph is rebuilt many times in one process. A one-shot production build should stay on build(): you compile once, write to disk, and exit, so there is nothing to keep resident and the extra dispose() bookkeeping is pure overhead. Likewise, if all you need is to transpile a single file with no bundling — stripping TypeScript types, downleveling one module — reach for the stateless transform() API instead, which never touches the file system or the resolver and has no lifecycle to manage; see Using esbuild transform API for TypeScript stripping. The context is also the wrong tool inside a CI pipeline that builds once and tears the runner down; there is no interactive edit loop to accelerate, so build() is simpler and just as fast for the single compile. Reserve context() for the interactive case it was designed for: a developer saving files repeatedly against a graph that stays loaded the whole session.
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.