Implementing Hot Module Replacement (HMR) for esbuild: Module Loader, Resolver, and Bundling Strategies
This article describes how to add Hot Module Replacement to esbuild by disabling scope hosting, converting modules to CommonJS, building a custom ModuleLoader and resolver, handling module URLs, and comparing two bundling approaches while also covering React Refresh integration.
esbuild is a very fast bundler but it does not provide Hot Module Replacement (HMR); developers must rely on full page reloads, which hurts the development experience. To address this, the article proposes adding Bundler‑based HMR and documents the challenges encountered.
Module Loader : Because esbuild’s scope‑hosting optimization blurs module boundaries, it must be disabled, forcing a custom bundling step. Inspired by Webpack, each module’s code is transformed to CommonJS using esbuild’s transform API, with special handling for dynamic imports, runtime helpers, and macro replacements (e.g., process.env.NODE_ENV). The transformed code is then wrapped in a custom ModuleLoader runtime that preserves __esModule flags and computed exports.
// a.ts
import { value } from 'b'
moduleLoader.registerLoader('a'/* /path/to/a */, (require, module, exports) => {
const { value } = require('b');
});The loader’s exports setter copies enumerable properties and respects the __esModule marker, ensuring compatibility with both ESM‑to‑CJS conversion and circular dependencies.
Module Resolver : After conversion, the system cannot resolve aliases or node_modules paths. Two solutions are discussed: (1) rewriting import URLs (as Webpack/Vite do) and (2) maintaining a runtime mapping table. The article chooses the mapping table approach to avoid the overhead of static analysis.
moduleLoader.registerResolver('a'/* /path/to/a */, {
'b': '/path/to/b'
});HMR Mechanism : When a module changes, the HMR API ( module.hot.accept) marks the module as a boundary. The runtime builds a minimal HMR bundle for the changed module, notifies the browser via WebSocket, and the browser re‑executes the updated module and its callbacks based on the dependency graph.
Bundling Strategies : Two implementations are compared. The first uses magic-string with source‑map remapping, but suffers from performance penalties due to lack of caching. The second leverages webpack-sources ( ConcatSource, CachedSource, SourceMapSource) which caches sourcemaps and yields a 10‑plus× speedup on incremental builds.
import { ConcatSource, CachedSource, SourceMapSource } from 'webpack-sources';
function bundle() {
const concat = new ConcatSource();
concat.add(module1);
concat.add(module2);
const { source, map } = concat.sourceAndMap();
return { code: source, map };
}The article also notes that esbuild’s production‑mode metafile.inputs may omit pure‑code modules, requiring a full module‑graph traversal to ensure all code is included.
Additional Topics : A brief discussion of lazy compilation (dynamic imports) and an outline of integrating React Refresh, including the Babel plugin transformation, runtime registration, and entry‑point injection, is provided.
var _jsxDevRuntime = require('node_modules/react/jsx-dev-runtime.js');
function FunctionDefault() {
return _jsxDevRuntime.jsxDEV('h1', { children: 'Default Export Function' }, 0, false, { fileName: '<...>', lineNumber: 2, columnNumber: 10 }, this);
}Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
ByteDance Web Infra
ByteDance Web Infra team, focused on delivering excellent technical solutions, building an open tech ecosystem, and advancing front-end technology within the company and the industry | The best way to predict the future is to create it
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
