esbuild icon indicating copy to clipboard operation
esbuild copied to clipboard

Reloading causes a rebuild in watch mode

Open mbrevda opened this issue 3 months ago • 8 comments

It seems that when I refresh the page in server + watch mode, a rebuild is triggered. Considering the otherwise stability of this project, I agree this sounds a bit implausible. Is there some way to see what triggered a rebuild so that I can start debugging this?

edit: I've verified with fswatch that nothing on disk is actually changing

mbrevda avatar Sep 11 '25 15:09 mbrevda

This is by design. From the documentation:

With esbuild's web server, each incoming request starts a rebuild if one is not already in progress, and then waits for the current rebuild to complete before serving the file. This means esbuild never serves stale build results.

evanw avatar Sep 11 '25 15:09 evanw

Thanks @evanw for the quick response!

Interesting (and my bad for missing it in the docs). The issue I was experiencing was that a slow plugin caused rebuilds to take a significant amount of time (~7-9s), making page refreshes extremely painful and negatively impacting the developer experience.


Is this really the best way? Shouldn't watch mode ensure that there is always a fresh build*? And especially when triggered by live reloading (which indicates that a build was successfully completed), is it really necessary to do a full rebuild when the page is re-requested?

Further, due to rigidity in the configs, I have some other build jobs that also add static files to the outdir. Loading these files (e.g. favicon) also seems to trigger a full rebuild, even though nothing in the code changed.

*writing this, and as a very long-time user, it only now dawned on me that serve + write likely leads to a double build, and in any event, it's certainly not necessary to watch when serving


I'm thinking of implementing my own server. However, watch() doesn't offer a convenient way to notify when a build is complete, which would be useful in implementing live reload.

I guess I could just proxy the event stream, but that seems a bit unergonomic, no?


Here are some things that could enhance the devex (not necessarily related to this issue):

  1. A debug log explaining what triggered a rebuild (which file changes, HTTP requests, etc). Having this would have made tracking down this "issue" much simpler and/or
  2. a watch() callback containing the above
  3. a server() callback containing the above
  4. a server() callback that can return the number of currently connected clients on the event stream (useful in deciding if a browser should be opened, such as on first run)
  5. A server() callback for non-found actions, instead fallback

mbrevda avatar Sep 11 '25 18:09 mbrevda

Thanks for the suggestions.

The issue I was experiencing was that a slow plugin caused rebuilds to take a significant amount of time (~7-9s), making page refreshes extremely painful and negatively impacting the developer experience.

Ah that's very slow indeed. My condolences. I have not tuned esbuild's built-in behavior for builds which are that slow. Ideally plugins that do something expensive can use caching to avoid redoing that work on subsequent builds when it's not needed. That way building again would be much faster and this would be more of a non-issue (as well as being more pleasant to use in general). Caching correctly can be non-trivial in many cases, however.

However, watch() doesn't offer a convenient way to notify when a build is complete, which would be useful in implementing live reload.

The way to do this is a plugin with an onEnd callback. This is a general way of observing build behavior that's not specific to watch mode.

  1. A debug log explaining what triggered a rebuild (which file changes, HTTP requests, etc). Having this would have made tracking down this "issue" much simpler and/or

That already exists for watch mode. It looks like this:

$ esbuild --bundle index.ts --servedir=out --outdir=out --watch

 > Local:   http://127.0.0.1:8000/
 > Network: http://192.168.0.1:8000/

[watch] build finished, watching for changes...
[watch] build started (change: "index.ts")
[watch] build finished
[watch] build started (change: "foo.ts")
[watch] build finished

You can enable that log from the JS API by adjusting the logLevel:

import * as esbuild from 'esbuild'

const ctx = await esbuild.context({
  entryPoints: ['index.ts'],
  bundle: true,
  outdir: 'out',
  logLevel: 'info',
})

await ctx.serve({
  servedir: 'out',
})

await ctx.watch()

Good point about including HTTP requests in that log as well though. I'll consider that.

evanw avatar Sep 12 '25 21:09 evanw

Thanks for the thoughtful response. I'm still troubled by why:

  1. Every page load via server() requires a rebuild
  2. Live reload, which by definition is triggered when a build is complete, triggers a new page reload once the page has refreshed

mbrevda avatar Sep 15 '25 16:09 mbrevda

Rather than rebuilding on every HTTP request, why not integrate serving more closely with the file watcher?

I like these options:

  1. add a rebuild?: boolean option to ctx.serve to disable the rebuild calls entirely
  2. automatically disable rebuild if the file watcher is active
  3. as above, but also wait for any pending builds to complete
  4. if watcher is active, make serve wait for the watcher to complete a partial or complete poll loop (and optional rebuild) before returning, requiring tracking when w.itemsToScan was last refilled and sending a signal when it finishes (?)

The current combination of watch/server/EventSource is quite odd, every edit I make triggers three to four rebuilds in quick succession-- one from the watcher, one from the http server when the page reloads, and another one or two when a trailing asynchronous script is fetched.

rmmh avatar Oct 18 '25 00:10 rmmh

Maybe in the serve mode esbuild should disable "rebuild on request" after the first build task when watch mode is also enabled. Should we assume that since the watch mode is on, it is not necessary to rebuild on request since the last build result is correct (all input files are not changed since then).

One concern is for example a plugin returns different things on each build, like import { now } from 'virtual:currentTime'. Currently the only hint for plugins to use is the watchDirs/watchFiles fields. Which is to say, we must assume the build result is only related to (determined by) the file system.

Also it is completely possible to do what I said through the JS API directly. The users only have to use the watch mode (disable serve mode) and capture the build output in memory and make a simple dev server manually.

hyrious avatar Oct 18 '25 01:10 hyrious

There's actually already functionality that would fix things if exposed as an option: https://github.com/evanw/esbuild/blob/2ba0f0233497ebe9f355aa0e7a12729560f43320/pkg/api/api_impl.go#L1015-L1031

If that 250 * time.Millisecond was instead based on some serve flag (--serve-freshness?) the user could choose to never expire recent builds (negative freshness), always do a rebuild (zero freshness), or set the freshness based on personal prefence like if their builds take a long time (positive freshness).

I started working on a PR (see commit above) and this works for the esbuild binary, but I'm stuck on the service protocol piece to integrate esbuild run under node. It's probably a very easy PR for someone familiar with adding a new option.

rmmh avatar Oct 18 '25 02:10 rmmh

For starters, understanding why things are currently designed to always rebuild on every request would also be helpful. @evanw, could you please explain this current design?

mbrevda avatar Oct 19 '25 06:10 mbrevda