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);
}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.