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().

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. 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.

context() replaced the removed watch:true The legacy watch:true and incremental:true build options were removed in 0.17; the modern path is context() from 0.18 with watch() and serve(), so build({watch}) throws on any version this guide targets. build({ watch: true })removed in 0.17 — throws context() + watch() + serve()0.18+ · in-memory graph
Figure: watch: true is gone — the modern loop is one context() plus watch()/serve().

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

Three checks before switching to a context Check for the legacy watch:true pattern that throws, measure the current rebuild cost since a build() loop re-parses the whole graph at 100 to 250ms versus 15 to 40ms for a context rebuild, then decide watch for on-disk consumers or serve for HTTP from memory. 1 legacy pattern?watch:true throws 2 measure cost250ms vs 30ms 3 watch vs servedisk vs HTTP
Figure: a build() loop re-parses the whole graph; a context reuses it — often a 5–8× rebuild speedup.
  1. Check for the legacy pattern. If your script passes watch: true to esbuild.build(), modern esbuild throws Invalid 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 into esbuild.context() and call watch() 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-shot build() does.
  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') around the call and save the same file ten times to get a stable median; on a few-hundred-file project a fresh build() 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.
  3. 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. Use serve() 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 is watch() for a sidecar tool plus serve() for the browser. Getting this choice wrong is cheap to correct — it is one or two extra lines — but choosing serve() when a separate process is reading dist/ from disk will leave that process staring at stale files, because serve() 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.

One context feeds watch, serve, onEnd, and dispose A single context created once feeds ctx.watch for incremental rebuilds and ctx.serve for HTTP; a plugin onEnd hook reports timing and surfaces errors; and SIGINT or SIGTERM triggers ctx.dispose to free the watchers and Go subprocess. context() oncegraph in memory ctx.watch() ctx.serve() onEnd reports SIGINT → dispose()frees watchers
Figure: one context, three consumers, and a single mandatory 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

Three checks: rebuild, serve, dispose Confirm an incremental rebuild logs a Rebuilt line far below the cold-build time, confirm curl returns the freshly built output and updates on edit, and confirm Ctrl+C exits with code zero and no leaked watchers. save → Rebuilt in Nmsfar below cold build curl → fresh outputno restart Ctrl+C → exit 0no leaked watchers
Figure: rebuild speed, live serving, and a clean exit — the three signs the context loop is wired right.
  • 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. 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’s onEnd is being shadowed by a logLevel that is not silent.
  • 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. 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 that servedir is not mapping — serve() serves from the in-memory output keyed by the URL path, so /index.js must match the emitted filename under outdir.
  • 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; before dispose() 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/inotify or simply confirming pgrep -f esbuild returns 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

Four context-watch edge cases watch and serve return immediately and keep the event loop open so don't add a manual loop; only one dispose per context and calls after it throw; serve rebuilds on request and errors surface in onEnd; and migrating from watch:true moves onRebuild into a plugin onEnd hook. watch/serve return immediately they register and resolve; the watcher holds the loop open — no manual await. one dispose per context rebuild or watch after dispose throws — for hot-reload, dispose and recreate. serve serves the last good build errors surface in onEnd — report them or the server silently serves stale output. migrating from watch:true move onRebuild into a plugin onEnd hook and swap build({watch}) for context().
Figure: two lifecycle traps and two "the server silently misbehaves" traps.
  • 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. 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 move onRebuild, 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.