esbuild 22 ⚡ The Context API — Long-lived builds for watch & serve
The Context API is the single mechanism that replaced esbuild’s three separate incremental surfaces. Before version 0.17.0, rebuild used incremental: true on build(), watch used watch: true or watch: { onRebuild }, and serve used a top-level esbuild.serve() function. Each had its own lifecycle, its own cleanup method, and its own quirks. The release notes for 0.17.0 describe the replacement plainly: “This change removes the incremental and watch options as well as the serve() method, and introduces a new context() method” . The new context object holds the incremental state, and rebuild(), watch(), and serve() are all methods on it. The same context can run all three at once, which was not possible before .
The first build is no longer a special case. In the old API, the initial build was performed by build() and the subsequent builds were performed by result.rebuild(). The two paths were different, and the onRebuild callback only fired for the subsequent builds, never for the first . The context API unifies them: ctx.rebuild() performs a build, whether it is the first or the hundredth. The onEnd plugin callback fires after every build, including the first, which is why it replaced onRebuild . The unification removes a class of bugs where a build script handled the first build differently from the rest and the two paths drifted apart.
Key point: The Context API is the entry point for all long-lived builds. esbuild.context(options) returns a BuildContext with rebuild(), watch(), serve(), cancel(), and dispose() methods. The same context can run watch and serve simultaneously. The dispose() method is the single cleanup path for all three.
Why the Context API replaced the old surfaces
The separate lifecycle problem. The old esbuild.serve() function returned a ServeResult with its own stop() method and a wait promise. The old watch: true option returned a BuildResult with a stop() method. The old incremental: true option returned a BuildResult with a rebuild() method and a rebuild.dispose() method. Three different cleanup methods for three different surfaces, and none of them could be combined cleanly . The context object has one cleanup method: dispose(). The release notes describe this as “the common way to discard the context and free resources in all of these situations” .
The plugin lifecycle problem. The onRebuild callback was removed because it did not fire for the first build, which meant a plugin that needed to run code after every build had to handle the first build separately. The onEnd plugin hook was already the correct mechanism, and the 0.17.0 release made it the only mechanism . A plugin with an onEnd callback fires after every build, whether the build was triggered by rebuild(), watch(), or serve(). The callback receives the BuildResult, and it can return additional errors to fail the build .
The combined watch-and-serve problem. Before 0.17.0, running a dev server with live reload required combining esbuild.serve() with watch: true in a way that was not officially supported. The release notes for 0.17.0 explicitly state the fix: “You should now be able to use all three kinds of incremental builds (rebuild, serve, and watch) together on the same context object” . A dev server that watches the source and serves the output is now the straightforward combination of ctx.watch() and ctx.serve() on the same context .
The trade-off. The Context API is not a drop-in replacement for the old API in the sense that the old options are gone. A build script that passes watch: true to build() fails with an “Invalid option” error in 0.17.0 and later . The migration is a rewrite of the incremental portion of the build script, not a configuration change. The rewrite is small—a few lines—but it is mandatory.
a. The context lifecycle and the rebuild method
The esbuild.context(options) function returns a Promise that resolves to a BuildContext. The options are the same BuildOptions that build() accepts. The context does not perform an initial build on creation. The first build is triggered by ctx.rebuild().
// build.mjs — the context lifecycle
import * as esbuild from 'esbuild';
// Create the context. The options are the same as build().
// No build is performed yet.
const ctx = await esbuild.context({
entryPoints: ['src/app.ts'],
bundle: true,
outdir: 'dist',
});
// The first build is performed by rebuild().
// This is the same method that performs subsequent builds.
await ctx.rebuild();
// The context is now holding the incremental state.
// The dispose() method frees the resources.
await ctx.dispose();
The rebuild() method returns a BuildResult, and the result is the same shape that build() returns. A script that needs the errors or the metafile can inspect the result of each rebuild.
// rebuild-on-change.mjs — a custom file watcher
import * as esbuild from 'esbuild';
import chokidar from 'chokidar';
const ctx = await esbuild.context({
entryPoints: ['src/app.ts'],
bundle: true,
outdir: 'dist',
metafile: true,
});
// The initial build.
const initialResult = await ctx.rebuild();
if (initialResult.errors.length > 0) {
console.error(initialResult.errors);
process.exit(1);
}
// A custom watcher that is not tied to the esbuild file graph.
// This is a case that the CLI's --watch flag cannot express.
chokidar.watch('config').on('all', async () => {
const result = await ctx.rebuild();
console.log(`Rebuilt at ${new Date().toISOString()}`);
console.log(result.metafile.outputs);
});
The cancel() method is the escape hatch for a rebuild that is taking too long. It cancels the current build, and the rebuild() Promise rejects with a cancel error. The method is useful in an editor integration where the user has typed a new character while the previous build is still running .
b. The watch and serve methods
The ctx.watch() method starts the file watcher. The watcher monitors the files that were included in the previous build and triggers a rebuild when one of them changes. The method accepts an optional WatchOptions object, which includes a delay for debouncing rapid changes.
// watch.mjs — the watch method
const ctx = await esbuild.context({
entryPoints: ['src/app.ts'],
bundle: true,
outdir: 'dist',
});
// The initial build is required before watching.
// watch() does not perform a build.
await ctx.rebuild();
// Start the watcher.
// The delay option coalesces rapid changes into a single rebuild.
await ctx.watch({ delay: 200 });
// The context holds the watcher.
// The dispose() method stops it.
process.on('SIGINT', async () => {
await ctx.dispose();
process.exit(0);
});
The ctx.serve(options) method starts a local HTTP server. The options include servedir (the directory for static files), port, host, keyfile, and certfile. The method returns a ServeResult with the hosts and port that the server bound to.
// serve.mjs — the serve method
const ctx = await esbuild.context({
entryPoints: ['src/app.ts'],
bundle: true,
outdir: 'www/js',
});
// Start the server. The build output is served from memory.
// The servedir is the fallback for static files like index.html.
const { hosts, port } = await ctx.serve({
servedir: 'www',
port: 3000,
});
console.log(`Serving at http://${hosts[0]}:${port}`);
// The serve method and the watch method can be combined.
await ctx.watch();
The combination of watch() and serve() is the dev server pattern. The watcher rebuilds the output when the source changes, and the server serves the new output. The server does not inject a live-reload script automatically; the client must add the EventSource('/esbuild') listener to reload the page when the build changes . The serve() method exposes the /esbuild endpoint, and the client connects to it.
Complete Example Session
// ============================================
// PART 1: THE MIGRATION FROM THE OLD API
// ============================================
// Before (esbuild < 0.17):
// const result = await esbuild.build({ ...options, watch: true });
// result.stop();
// After (esbuild >= 0.17):
const ctx = await esbuild.context(options);
await ctx.watch();
// await ctx.dispose(); // to stop
// The watch option is gone.
// The context is the replacement.
// ============================================
// PART 2: THE REBUILD METHOD
// ============================================
const ctx = await esbuild.context({
entryPoints: ['src/app.ts'],
bundle: true,
outdir: 'dist',
});
// The first build and the subsequent builds use the same method.
const result = await ctx.rebuild();
console.log(result.errors);
// The context holds the state between builds.
// Only the changed files are re-processed.
// ============================================
// PART 3: THE WATCH METHOD
// ============================================
// The watch method starts the file watcher.
// The initial build must be performed separately.
await ctx.rebuild();
await ctx.watch();
// The delay option debounces rapid changes.
// It is useful when a code generator writes multiple files.
await ctx.watch({ delay: 300 });
// ============================================
// PART 4: THE SERVE METHOD
// ============================================
// The serve method starts a local HTTP server.
const { hosts, port } = await ctx.serve({
servedir: 'www',
port: 3000,
});
console.log(`http://${hosts[0]}:${port}`);
// ============================================
// PART 5: THE COMBINED DEV SERVER
// ============================================
// Watch and serve on the same context.
// This is the dev server pattern.
const ctx = await esbuild.context({
entryPoints: ['src/app.ts'],
bundle: true,
outdir: 'www/js',
});
await ctx.rebuild();
await ctx.watch();
const { port } = await ctx.serve({ servedir: 'www' });
// The client script connects to the /esbuild endpoint.
// The server sends a change event when the build output changes.
// ============================================
// PART 6: THE DISPOSE METHOD
// ============================================
// The dispose method is the single cleanup path.
// It stops the watcher, stops the server, and frees the resources.
await ctx.dispose();
// ============================================
// PART 7: THE onEnd PLUGIN CALLBACK
// ============================================
// The onRebuild callback was removed.
// The onEnd plugin callback is the replacement.
// It fires after every build, including the first.
const plugins = [
{
name: 'build-logger',
setup(build) {
build.onEnd(result => {
if (result.errors.length > 0) {
console.error('Build failed:', result.errors);
} else {
console.log('Build succeeded at', new Date().toISOString());
}
});
},
},
];
const ctx = await esbuild.context({
entryPoints: ['src/app.ts'],
bundle: true,
outdir: 'dist',
plugins,
});
await ctx.watch();
// ============================================
// PART 8: THE CANCEL METHOD
// ============================================
// The cancel method cancels the current build.
// The rebuild() Promise rejects with a cancel error.
ctx.cancel();
// ============================================
// PART 9: THE WATCH OPTION REMOVAL
// ============================================
// The old code fails:
// await esbuild.build({ watch: true }); // ❌ Invalid option
// The new code:
const ctx = await esbuild.context({ /* options */ });
await ctx.watch();
// ============================================
// PART 10: THE VERIFICATION CHECKLIST
// ============================================
// [ ] The context is created with esbuild.context().
// [ ] The initial build is performed with ctx.rebuild().
// [ ] The watch is started with ctx.watch().
// [ ] The serve is started with ctx.serve().
// [ ] The context is disposed with ctx.dispose().
// [ ] The onEnd plugin callback replaces onRebuild.
// [ ] The watch and serve run on the same context.
The ten parts covered the migration, the rebuild, the watch, the serve, the combined dev server, the dispose, the onEnd callback, the cancel, the watch removal, and the verification. The through-line is that the context object is the single lifecycle for all incremental builds.
Quick Reference
The context methods
| Method | Purpose | Returns |
|---|---|---|
esbuild.context(options) | Create a build context | Promise<BuildContext> |
ctx.rebuild() | Perform a build | Promise<BuildResult> |
ctx.watch(options?) | Start the file watcher | Promise<void> |
ctx.serve(options) | Start the dev server | Promise<ServeResult> |
ctx.cancel() | Cancel the current build | void |
ctx.dispose() | Free resources | Promise<void> |
The watch options
| Option | Type | Purpose |
|---|---|---|
delay | number | Debounce interval in milliseconds |
The serve options
| Option | Type | Purpose |
|---|---|---|
servedir | string | Static files directory |
port | number | Server port |
host | string | Listen address |
The migration
| Old API | New API |
|---|---|
build({ watch: true }) | context() + watch() |
build({ incremental: true }) | context() + rebuild() |
esbuild.serve(options, buildOptions) | context() + serve(options) |
result.stop() | ctx.dispose() |
watch: { onRebuild } | onEnd plugin callback |
Best Practices
✅ Do This:
// Use context() for long-lived builds
const ctx = await esbuild.context(options); await ctx.watch(); // ✅
// Perform the initial build with rebuild()
await ctx.rebuild(); await ctx.watch(); // ✅
// Combine watch and serve on the same context
await ctx.watch(); await ctx.serve({ servedir: 'www' }); // ✅
// Use onEnd for post-build logic
build.onEnd(result => { /* ... */ }); // ✅
❌ Don’t Do This:
// Pass watch to build()
await esbuild.build({ watch: true }); // ❌
// Use the old serve function
await esbuild.serve(options, buildOptions); // ❌
// Use onRebuild
watch: { onRebuild: () => {} } // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
watch: true is rejected | Removed in 0.17.0 | Use context() + ctx.watch() |
| The initial build does not run | watch() does not build | Call ctx.rebuild() first |
| The process does not exit | The watcher holds the handle | Call ctx.dispose() on exit |
onRebuild is never called | Removed in 0.17.0 | Use onEnd plugin callback |
| Serve and watch conflict | Old separate APIs | Use the same context for both |
Real-World Examples
1. Basic context
const ctx = await esbuild.context({ entryPoints: ['app.ts'], bundle: true, outdir: 'dist' });
2. Rebuild
await ctx.rebuild();
3. Watch
await ctx.watch();
4. Serve
const { port } = await ctx.serve({ servedir: 'www' });
5. Watch + serve
await ctx.watch(); await ctx.serve({ servedir: 'www' });
6. Dispose
await ctx.dispose();
7. onEnd
build.onEnd(result => console.log(result.errors));
8. Custom watcher
chokidar.watch('src').on('all', () => ctx.rebuild());
9. Migration
// Before: build({ watch: true })
// After: context() + watch()
10. Cancel
ctx.cancel();
Visual
The Context Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ context() → rebuild() / watch() / serve() → dispose() │
│ │
│ const ctx = await esbuild.context(options) │
│ │ │
│ ├──▶ await ctx.rebuild() (manual build) │
│ ├──▶ await ctx.watch() (file watcher) │
│ └──▶ await ctx.serve() (dev server) │
│ │
│ await ctx.dispose() (free resources) │
│ │
│ The context holds the incremental state between builds. │
│ The same context can run all three methods. │
└──────────────────────────────────────────────────────────────┘
The Old vs New API
┌──────────────────────────────────────────────────────────────┐
│ BEFORE 0.17.0 AFTER 0.17.0 │
│ │
│ build({ watch: true }) context() + watch() │
│ build({ incremental: true }) context() + rebuild() │
│ esbuild.serve() context() + serve() │
│ │
│ Three surfaces One context │
│ Three cleanup methods One dispose() │
└──────────────────────────────────────────────────────────────┘
The onEnd Callback
┌──────────────────────────────────────────────────────────────┐
│ onRebuild vs onEnd │
│ │
│ onRebuild (removed) onEnd (replacement) │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Fires only for │ │ Fires for every │ │
│ │ subsequent builds │ │ build, including │ │
│ │ │ │ the first │ │
│ │ Not called for the │ │ │ │
│ │ initial build │ │ Consistent lifecycle │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
│ The onEnd hook is the correct mechanism for post-build │
│ logic. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Entry point | esbuild.context(options) |
| Rebuild method | ctx.rebuild() |
| Watch method | ctx.watch({ delay }) |
| Serve method | ctx.serve({ servedir, port }) |
| Cleanup | ctx.dispose() |
| Cancel | ctx.cancel() |
| Post-build hook | onEnd plugin callback |
| Watch option | Removed in 0.17.0 |
Key takeaways:
- The Context API is the single entry point for all long-lived builds. The
context()function returns an object withrebuild(),watch(), andserve()methods. The same context can run all three simultaneously, which was not possible with the separate old APIs . - The
watchandincrementaloptions were removed frombuild(). A build script that passeswatch: truetobuild()fails on 0.17.0 and later. Thecontext()function is the replacement . - The
dispose()method is the single cleanup path. It stops the watcher, stops the server, and frees the incremental state. The old API had three different cleanup methods; the context has one . - The
onEndplugin callback replacesonRebuild. The old callback only fired for subsequent builds, never for the first. The new callback fires after every build, which makes the lifecycle consistent . - The initial build is performed by
rebuild(). Thecontext()function does not perform a build on creation. The first build must be triggered explicitly, and it uses the same method as the subsequent builds .
Remember: The Context API is the lifecycle object for incremental builds. The context() function creates it, the rebuild(), watch(), and serve() methods operate on it, and the dispose() method cleans it up. The old separate surfaces—build({ watch: true }), build({ incremental: true }), and esbuild.serve()—were removed in 0.17.0. The migration is a rewrite of the incremental portion of the build script, and it is a few lines. The onEnd plugin callback is the post-build hook. The context is the single object that holds it all together.
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!