| | |

esbuild 19 ⚡ Watch Mode — Rebuilding on file changes

⏱️ Estimated read: 15 min | |

esbuild’s watch mode is not a configuration flag on the build function. Since version 0.17.0, the watch option was removed from esbuild.build() and replaced by the context API: esbuild.context() creates a build context, and ctx.watch() starts the watcher . The API change is not cosmetic. The context object holds the incremental state between builds, and the watch, serve, and rebuild methods all operate on that shared state. A build script that passes watch: true to build() will fail on modern esbuild with a type error or a rejection, depending on how the options are typed.

The watcher does one thing: it monitors the files that were included in the previous build and triggers a rebuild when one of them changes. It does not monitor files that were not part of the build. It does not monitor HTML files that reference the output, unless they are passed through a loader or plugin that includes them in the build. It does not provide hot module replacement. It provides a rebuild, and the rebuild is incremental—esbuild reuses the parts of the previous build that did not change . The speed of that incremental rebuild is the feature.

Key point: Watch mode is the ctx.watch() method on a context object created by esbuild.context(). It rebuilds when a file included in the previous build changes. It does not watch files outside the build, and it does not provide HMR.


Why the context API replaced the watch option

The incremental state problem. The old watch: true option created an implicit context internally, but the context was not exposed to the caller. This made it impossible to combine watch with serve or rebuild on the same build configuration, because each feature created its own internal state. The context API exposes the state. A single esbuild.context() call creates one context, and ctx.watch(), ctx.serve(), and ctx.rebuild() all operate on it . The three incremental features can now be combined on the same context, which is what makes the --watch --serve CLI combination possible without duplicating the build configuration.

The onRebuild removal. The old watch API accepted an onRebuild callback that fired after each rebuild. The callback did not fire for the first build, which meant a build script that relied on it for logging or error handling missed the initial result. The context API removes onRebuild entirely. The replacement is a plugin with an onEnd callback, which fires after every build including the first . The plugin approach is more verbose, but it is consistent: every build goes through the same lifecycle, and the first build is not a special case.

The file inclusion constraint. The watcher monitors the files that esbuild resolved during the previous build. A file that is imported by an entry point is watched. A file that is referenced by an HTML file but not imported by the JavaScript is not watched, unless a plugin or a loader includes it in the build graph . This is the most common source of confusion: a developer edits an HTML template, the watcher does not rebuild, and the developer concludes the watcher is broken. The watcher is not broken; the HTML file was never part of the build. The fix is to copy the HTML file through a loader or a plugin so that esbuild knows about it.

The trade-off. The watcher is fast because it only rebuilds the parts of the graph that changed. But it only knows about the parts of the graph it built. A project that relies on files outside the build graph—static HTML, configuration files read at runtime, environment files—must either include those files in the build or accept that changes to them require a manual rebuild. The watcher is not a general file system watcher; it is a build-graph watcher.


a. Using the context API for watch mode

The JavaScript API requires creating a context and calling watch(). The CLI flag --watch handles the context creation internally.

// build.mjs — watch mode with the context API
import * as esbuild from 'esbuild';

// Create the context with the same options you would pass to build().
const ctx = await esbuild.context({
  entryPoints: ['src/app.js'],
  bundle: true,
  outdir: 'dist',
  // The context holds the incremental state.
  // Every subsequent watch rebuild reuses what it can.
});

// Start watching. This does not block; it returns when the watcher is ready.
await ctx.watch();

console.log('Watching for changes...');

// The process stays alive because the watcher holds a handle.
// To stop watching and free resources, call ctx.dispose().
// await ctx.dispose();

The ctx.watch() call does not perform an initial build. The initial build must happen separately—either by calling ctx.rebuild() before watch(), or by relying on the CLI’s behavior, which performs an initial build before watching. The JavaScript API does not perform the initial build automatically because the context is a lower-level primitive; the caller decides when the first build happens .

// build.mjs — explicit initial build before watching
import * as esbuild from 'esbuild';

const ctx = await esbuild.context({
  entryPoints: ['src/app.js'],
  bundle: true,
  outdir: 'dist',
});

// Perform the initial build.
await ctx.rebuild();

// Start watching for subsequent changes.
await ctx.watch();

The CLI form is simpler and performs the initial build automatically. The --watch flag is the only addition needed.

# CLI watch mode — performs an initial build and then watches
esbuild src/app.js --bundle --outdir=dist --watch

The CLI prints a message when the initial build completes and a message for each subsequent rebuild. The output format changed in an early version of esbuild, and the current format is stable .


b. The watch delay and the rebuild lifecycle

The watcher fires a rebuild when a file changes. If multiple files change in rapid succession—a code generator that writes several files, or an editor that saves a file and its companion—the watcher may trigger multiple rebuilds, and some of them may see an intermediate state that is not consistent. The delay option adds a debounce interval before the rebuild starts.

// build.mjs — watch with a delay
const ctx = await esbuild.context({
  entryPoints: ['src/app.js'],
  bundle: true,
  outdir: 'dist',
});

await ctx.watch({
  // Wait 200ms after the last change before rebuilding.
  // This coalesces rapid changes into a single rebuild.
  delay: 200,
});

The delay option is available in the API and the CLI as --watch-delay= . It is useful when a tool regenerates multiple files in sequence; without the delay, the watcher may rebuild after the first file is written but before the last one, producing a build that references a file that does not exist yet. The delay does not prevent every intermediate state, but it reduces the window.

The rebuild lifecycle is exposed through the onEnd plugin callback. The callback fires after every build, including the first, and receives the build result or the error.

// build.mjs — logging rebuild results with onEnd
const ctx = await esbuild.context({
  entryPoints: ['src/app.js'],
  bundle: true,
  outdir: 'dist',
  plugins: [
    {
      name: 'watch-logger',
      setup(build) {
        build.onEnd(result => {
          // This fires after every build: initial and rebuilds.
          if (result.errors.length > 0) {
            console.error('Build failed:', result.errors);
          } else {
            console.log('Build succeeded at', new Date().toISOString());
          }
        });
      },
    },
  ],
});

await ctx.watch();

The onEnd callback is the replacement for the removed onRebuild callback. It fires for the first build, which the old callback did not, and it is the only supported way to run code after every build in watch mode . A build script that needs to copy files, notify a browser, or log results uses onEnd, not a callback on the watch method itself.


c. What the watcher does not watch

The watcher monitors the files that were resolved during the previous build. It does not monitor files that were not part of the build. This is the most important limitation to understand, because it produces the most common “the watcher is broken” report.

// build.mjs — an HTML file that is not in the build graph
const ctx = await esbuild.context({
  entryPoints: ['src/app.js'],
  bundle: true,
  outdir: 'dist',
  // index.html is not an entry point.
  // It is not imported by app.js.
  // The watcher does not know it exists.
});

await ctx.watch();
// Editing index.html does not trigger a rebuild.

The fix is to include the HTML file in the build. The copy loader is the simplest mechanism: it copies the file to the output directory and includes it in the build graph, so the watcher monitors it and the rebuild copies the changed version .

// build.mjs — HTML included via the copy loader
const ctx = await esbuild.context({
  entryPoints: ['src/app.js', 'src/index.html'],
  bundle: true,
  outdir: 'dist',
  loader: {
    // The copy loader copies the file and includes it in the graph.
    '.html': 'copy',
  },
});

await ctx.watch();
// Editing index.html now triggers a rebuild.

The same limitation applies to CSS files that are referenced by HTML but not imported by JavaScript, and to any other asset that lives outside the module graph. If the watcher needs to see it, the build must include it.

The watcher also does not provide hot module replacement. It rebuilds the output; the browser must reload to see the change. The --serve flag with --watch provides a live-reload mechanism through an event source, but that is a separate feature layered on top of the watch mode, not part of the watch mode itself . The watch mode rebuilds; the serve mode reloads. They are independent.


Complete Example Session

// ============================================
// PART 1: THE MINIMAL WATCH CONFIGURATION
// ============================================

// build.mjs
import * as esbuild from 'esbuild';

const ctx = await esbuild.context({
  entryPoints: ['src/app.js'],
  bundle: true,
  outdir: 'dist',
});

// Initial build, then watch.
await ctx.rebuild();
await ctx.watch();

// ============================================
// PART 2: THE CLI EQUIVALENT
// ============================================

// esbuild src/app.js --bundle --outdir=dist --watch
//
// The CLI performs the initial build and starts watching.
// The process stays alive until interrupted.

// ============================================
// PART 3: THE REBUILD LOGGER
// ============================================

// build.mjs — logging every build result
const ctx = await esbuild.context({
  entryPoints: ['src/app.js'],
  bundle: true,
  outdir: 'dist',
  plugins: [
    {
      name: 'logger',
      setup(build) {
        build.onEnd(result => {
          // Fires for the initial build and every rebuild.
          const status = result.errors.length > 0 ? 'failed' : 'succeeded';
          console.log(`[${new Date().toISOString()}] Build ${status}`);
        });
      },
    },
  ],
});

await ctx.watch();

// ============================================
// PART 4: THE WATCH DELAY
// ============================================

// build.mjs — debouncing rapid changes
await ctx.watch({
  delay: 300, // milliseconds
});

// The delay is useful when a code generator writes
// multiple files in sequence. Without it, the watcher
// may rebuild after the first file and see an incomplete state.

// ============================================
// PART 5: INCLUDING HTML IN THE WATCH GRAPH
// ============================================

// build.mjs — watching HTML changes
const ctx = await esbuild.context({
  entryPoints: ['src/app.js', 'src/index.html'],
  bundle: true,
  outdir: 'dist',
  loader: {
    '.html': 'copy',
  },
});

await ctx.watch();

// Now editing index.html triggers a rebuild,
// and the updated file is copied to dist/.

// ============================================
// PART 6: THE DISPOSE PATTERN
// ============================================

// build.mjs — cleanup on exit
const ctx = await esbuild.context({
  // ...
});

await ctx.watch();

// On SIGINT, dispose the context to free resources.
process.on('SIGINT', async () => {
  await ctx.dispose();
  process.exit(0);
});

// ============================================
// PART 7: WATCH AND SERVE TOGETHER
// ============================================

// build.mjs — watch + serve for live reload
const ctx = await esbuild.context({
  entryPoints: ['src/app.js'],
  bundle: true,
  outdir: 'www',
});

await ctx.watch();
const { port } = await ctx.serve({
  servedir: 'www',
});

console.log(`Serving at http://localhost:${port}`);

// The serve method provides the live-reload endpoint.
// The watch method provides the rebuilds.
// They share the same context and the same build configuration.

// ============================================
// PART 8: THE FILE INCLUSION TRAP
// ============================================

// ❌ This does not watch index.html.
// The HTML file is not in the build graph.
const ctx = await esbuild.context({
  entryPoints: ['src/app.js'], // HTML not included
  bundle: true,
  outdir: 'dist',
});

// ✅ This watches index.html.
const ctx = await esbuild.context({
  entryPoints: ['src/app.js', 'src/index.html'],
  bundle: true,
  outdir: 'dist',
  loader: { '.html': 'copy' },
});

// ============================================
// PART 9: WHAT THE WATCHER MONITORS
// ============================================

// The watcher monitors:
// - Entry points passed to the build.
// - Modules imported by entry points (directly or transitively).
// - Files loaded by loaders that are part of the graph.
// - Files included by plugins that add them to the graph.
//
// The watcher does not monitor:
// - HTML files not included in the build.
// - Configuration files read at runtime.
// - Environment files loaded by the application.
// - Files in node_modules that are not bundled.

// ============================================
// PART 10: THE VERIFICATION CHECKLIST
// ============================================

// [ ] The context is created with esbuild.context().
// [ ] The initial build is performed (rebuild or CLI).
// [ ] ctx.watch() is called.
// [ ] onEnd plugin logs every build result.
// [ ] HTML files are included via the copy loader if needed.
// [ ] delay is set if rapid changes cause intermediate builds.
// [ ] ctx.dispose() is called on exit.

The ten parts covered the context API, the CLI equivalent, the rebuild logger, the watch delay, the HTML inclusion trap, the dispose pattern, and the watch-plus-serve combination. The through-line is that the watcher is a build-graph watcher, and every limitation follows from that fact.


Quick Reference

Watch mode API

APIPurpose
esbuild.context(options)Create a build context
ctx.rebuild()Perform a build (initial or manual)
ctx.watch(options?)Start watching for changes
ctx.serve(options?)Start a local server with live reload
ctx.dispose()Free resources and stop watching

CLI flags

FlagEffect
--watchPerform initial build, then watch
--watch-delay=NDebounce rebuilds by N milliseconds
--serveStart a local server
--servedir=DIRServe static files from DIR

What is watched vs not watched

WatchedNot watched
Entry pointsHTML files outside the graph
Imported modulesConfiguration files
Files through copy loaderEnvironment files
Plugin-added filesUnbundled node_modules

Best Practices

✅ Do This:

// Use the context API
const ctx = await esbuild.context(options);                       // ✅

// Perform the initial build explicitly
await ctx.rebuild(); await ctx.watch();                           // ✅

// Include HTML in the graph
loader: { '.html': 'copy' }                                      // ✅

// Log every build with onEnd
build.onEnd(result => { /* ... */ });                             // ✅

// Dispose on exit
process.on('SIGINT', () => ctx.dispose());                        // ✅

❌ Don’t Do This:

// Pass watch: true to build()
await esbuild.build({ watch: true });                             // ❌ (removed in 0.17)

// Expect the watcher to see files outside the graph
// Editing an unlinked HTML file does nothing               // ❌

// Use onRebuild
watch: { onRebuild: () => {} }                                    // ❌ (removed)

// Forget the initial build in the JS API
await ctx.watch(); // No initial build                             // ❌

Common Pitfalls

PitfallWhy It HappensFix
watch option is rejectedesbuild 0.17+ removed itUse esbuild.context() + ctx.watch()
No initial build in JS APIctx.watch() does not buildCall ctx.rebuild() first
HTML changes are ignoredHTML not in the build graphAdd HTML as entry point with copy loader
Rapid changes cause broken buildsWatcher rebuilds on first file writeSet delay in watch() or --watch-delay
onRebuild is not calledRemoved in 0.17Use onEnd plugin callback

Real-World Examples

1. Basic JS watch

const ctx = await esbuild.context({ entryPoints: ['src/app.js'], bundle: true, outdir: 'dist' });
await ctx.rebuild(); await ctx.watch();

2. CLI watch

esbuild src/app.js --bundle --outdir=dist --watch

3. Watch delay

await ctx.watch({ delay: 200 });

4. HTML via copy loader

entryPoints: ['src/app.js', 'src/index.html'], loader: { '.html': 'copy' }

5. onEnd logger

build.onEnd(result => console.log(result.errors.length ? 'failed' : 'ok'));

6. Dispose on exit

process.on('SIGINT', () => ctx.dispose());

7. Watch + serve

await ctx.watch(); await ctx.serve({ servedir: 'www' });

8. CLI watch + serve

esbuild src/app.js --bundle --outdir=www --watch --servedir=www

9. No initial build in JS

await ctx.watch(); // ❌ no build
await ctx.rebuild(); await ctx.watch(); // ✅

10. Verify watched files

// Edit an imported module; the rebuild should fire.
// Edit an unlinked HTML file; no rebuild fires.

Visual

The Context API Lifecycle

┌──────────────────────────────────────────────────────────────┐
│  FROM BUILD TO WATCH                                          │
│                                                              │
│  esbuild.context(options)                                    │
│         │                                                    │
│         ▼                                                    │
│  ┌─────────────────┐                                        │
│  │ Context object  │  Holds incremental state                │
│  └────────┬────────┘                                        │
│           │                                                  │
│     ┌─────┴─────┬─────────────┐                             │
│     ▼           ▼             ▼                              │
│  ┌──────┐  ┌──────┐    ┌──────────┐                         │
│  │rebuild│  │watch │    │serve     │                         │
│  │      │  │      │    │          │                         │
│  │Build │  │Watch │    │HTTP +    │                         │
│  │once  │  │files │    │live      │                         │
│  └──────┘  └──────┘    │reload    │                         │
│                         └──────────┘                         │
│                                                              │
│  All three share the same context and the same options.      │
│  ctx.dispose() frees the context.                           │
└──────────────────────────────────────────────────────────────┘

What the Watcher Sees

┌──────────────────────────────────────────────────────────────┐
│  THE BUILD GRAPH IS THE WATCH LIST                            │
│                                                              │
│  src/app.js ──▶ src/utils.js ──▶ src/helpers.js             │
│       │                                                      │
│       └──▶ src/index.html (via copy loader)                 │
│                                                              │
│  Watched: app.js, utils.js, helpers.js, index.html          │
│                                                              │
│  Not watched:                                                │
│  ┌─────────────────────┐                                    │
│  │ src/other.html      │  Not in the graph                  │
│  │ .env                │  Not in the graph                  │
│  │ config.json         │  Not in the graph                  │
│  └─────────────────────┘                                    │
│                                                              │
│  Editing a file outside the graph does nothing.             │
│  Include it in the build to watch it.                       │
└──────────────────────────────────────────────────────────────┘

The onEnd Lifecycle

┌──────────────────────────────────────────────────────────────┐
│  EVERY BUILD FIRES onEnd                                     │
│                                                              │
│  Initial build                                               │
│  ┌─────────────────┐                                        │
│  │ ctx.rebuild()   │──▶ onEnd fires                          │
│  └─────────────────┘                                        │
│                                                              │
│  File change                                                 │
│  ┌─────────────────┐                                        │
│  │ watcher detects │──▶ rebuild ──▶ onEnd fires              │
│  └─────────────────┘                                        │
│                                                              │
│  Another file change                                         │
│  ┌─────────────────┐                                        │
│  │ watcher detects │──▶ rebuild ──▶ onEnd fires              │
│  └─────────────────┘                                        │
│                                                              │
│  onEnd does not skip the first build.                       │
│  Use it for logging, notifications, and post-build steps.   │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
APIesbuild.context() + ctx.watch()
CLI--watch
Initial buildRequired in JS API; automatic in CLI
Rebuild triggerChanges to files in the build graph
Delay optionwatch({ delay: ms }) or --watch-delay=
Rebuild callbackonEnd plugin hook (replaces onRebuild)
Cleanupctx.dispose()
Serve combinationctx.watch() + ctx.serve()

Key takeaways:

  • The watch option was removed from build(). Modern esbuild uses esbuild.context() and ctx.watch(). A build script that passes watch: true to build() fails on 0.17 and later .
  • The JavaScript API does not perform an initial build. ctx.watch() only watches. The initial build must be triggered with ctx.rebuild() before or after starting the watcher. The CLI performs the initial build automatically .
  • The watcher only sees the build graph. Files that are not entry points, imports, or loader-included files are not watched. HTML files that reference the output must be included via the copy loader or a plugin to be watched .
  • onRebuild is gone; use onEnd. The onEnd plugin hook fires after every build, including the first, and is the supported way to run code after rebuilds in watch mode .
  • The watcher does not provide HMR. It rebuilds the output. The browser must reload to see the change. The serve method provides live reload, but that is a separate feature that shares the context, not part of the watch mode itself .

Remember: Watch mode is a build-graph watcher, not a file system watcher. It monitors exactly the files that esbuild resolved during the previous build, and nothing else. A file that is edited but not part of the graph produces no rebuild, and no error. The fix is always to include the file in the graph—as an entry point, through a loader, or through a plugin. The watcher is fast because it only rebuilds what changed in the graph it knows. The limitation is the same as the feature: it knows what it built.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!