esbuild’s incremental builds can dramatically slash your development server startup and rebuild times, but only if you understand how it aggressively caches intermediate results.

Here’s how a typical development workflow looks with esbuild:

// src/index.js
import './style.css';
import './another-module.js';

console.log('Hello from index!');
// src/another-module.js
console.log('This is another module.');
/* src/style.css */
body {
  background-color: lightblue;
}

When you run esbuild src/index.js --bundle --outfile=out/bundle.js --servedir=out --sourcemap --watch, esbuild first performs a full build. It parses index.js, finds its dependencies (style.css, another-module.js), parses those, transforms them (e.g., CSS to JS modules), and bundles everything into out/bundle.js. The --servedir option starts a tiny HTTP server, and --watch tells esbuild to monitor your source files for changes.

Now, imagine you change src/another-module.js. Instead of re-parsing and re-transforming everything, esbuild only re-processes another-module.js. It then takes the cached, already-transformed output of index.js and style.css, and combines them with the newly transformed another-module.js to produce a fresh out/bundle.js. This is the core of incremental builds: reuse as much as possible.

The real magic happens when you don’t use --watch. Instead, you’d typically run esbuild within a Node.js script that manages the build process and watches for changes. This gives you finer control.

Consider this build.js script:

const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');

const entryPoint = 'src/index.js';
const outfile = 'out/bundle.js';
const servedir = 'out';

let ctx;

async function build() {
  if (ctx) {
    console.log('Rebuilding...');
    await ctx.rebuild();
    console.log('Rebuild complete.');
    return;
  }

  console.log('Initial build...');
  ctx = await esbuild.context({
    entryPoints: [entryPoint],
    bundle: true,
    outfile: outfile,
    sourcemap: true,
    servedir: servedir,
    // Incremental builds are enabled by default when using context
  });

  await ctx.rebuild();
  console.log('Initial build complete.');

  // Watch for changes
  fs.watch(path.dirname(entryPoint), { recursive: true }, async (eventType, filename) => {
    if (filename) {
      console.log(`Change detected in ${filename}. Triggering rebuild.`);
      await build(); // Call build again to trigger rebuild
    }
  });

  // Start the server
  await ctx.serve({ servedir });
  console.log(`Server started at http://localhost:8000 (serving ${servedir})`);
}

build().catch(err => {
  console.error('Build failed:', err);
  process.exit(1);
});

When you run node build.js, esbuild creates a build context (esbuild.context). This context holds onto the intermediate build artifacts. The first ctx.rebuild() does a full build. Subsequent calls to ctx.rebuild() within the same context are incremental. The fs.watch triggers these incremental rebuilds. The ctx.serve starts the development server.

The problem esbuild solves is the combinatorial explosion of build steps. In a large project, a single file change could theoretically require re-parsing, re-transforming, and re-linking thousands of files. esbuild’s incremental strategy breaks this down by only re-processing what’s absolutely necessary, leveraging cached representations of modules that haven’t changed.

The key levers you control are:

  • entryPoints: Which files kick off the build graph.
  • bundle: Whether to combine all modules into a single output file.
  • outfile: Where the final bundled output goes.
  • servedir: The directory to serve files from, useful for development.
  • sourcemap: Generates source maps for debugging.
  • esbuild.context(): This is crucial for enabling incremental builds. It creates a persistent build state.
  • ctx.rebuild(): This method performs a build using the cached state.

The truly surprising part is how little configuration is actually needed to enable incremental builds. They are the default behavior when you use esbuild.context(). The complexity isn’t in turning them on, but in integrating them into a robust watch and serve loop that correctly invalidates and triggers rebuilds. Many developers think they need to configure specific caching options, but the core mechanism is automatic once a context is established.

The next hurdle is managing complex asset pipelines, like integrating image optimization or custom Babel plugins, alongside esbuild’s speed.

Want structured learning?

Take the full Esbuild course →