Exploring Unbundled Development: From Webpack Bottlenecks to Vite‑Based Dev Server Solutions
This article examines the performance challenges of traditional bundled development with Webpack, evaluates emerging unbundled tools such as Snowpack, WMR, and Vite, and details the design and implementation of a custom unbundled dev server that accelerates startup, handles dependency preprocessing, resource transformation, and hot module replacement.
Modern browsers now support native ES modules (ESM), enabling tools like Snowpack, WMR, and Vite to delegate module resolution to the browser and achieve near‑instant dev‑server startup, a stark contrast to traditional bundlers that compile the entire dependency graph into a single JavaScript bundle.
Traditional Bundled Development
Webpack remains the dominant bundler, but as project size grows, bundling all source files and third‑party libraries together can take minutes to start a dev server, especially in large monorepos where common libraries are also compiled.
Common optimization guides (e.g., Webpack Build Performance Guide) suggest techniques like thread-loader with babel-loader , but these often break when projects use babel-plugin-import for on‑demand component loading. The snippet below shows a typical babel-plugin-import configuration that fails during thread‑loader serialization:
[
"babel-plugin-import",
{
"libraryName": "custom-ui-components",
"style": (name: string, file: Object) => {
return `${name}/style/2x`;
}
}
]While esbuild offers fast bundling, it still struggles with production‑grade needs such as ES5 down‑leveling, code‑splitting, and CSS handling, and replacing babel-loader with esbuild-loader does not always match Webpack’s speed.
Webpack 5’s experimental Lazy Compilation can compile modules on demand, but it remains unstable and may cause initial white‑screen issues.
Externalizing third‑party libraries as UMD scripts reduces bundle size but introduces global‑variable pollution and requires each dependency to provide a UMD build, which many internal packages lack.
Instead, loading ESM versions directly from CDNs (e.g., Skypack) is possible. Example using rollup-plugin-cdn :
import hyper from 'https://unpkg.com/hyperhtml@latest/esm/index.js';
hyper(document.body)`
Hello ESM
`;Or using @pika/cdn-webpack-plugin to replace package imports with CDN URLs (React example shown):
import __mun2tz2a_default, * as __mun2tz2a_all from "https://cdn.skypack.dev/react";
window["https://cdn.skypack.dev/react"] = Object.assign((__mun2tz2a_default || {}), __mun2tz2a_all);
(window["webpackJsonptask_activity"] = window["webpackJsonptask_activity"] || []).push([["vendors"],{
/***/ "50ab":
/*!****************************************!*
!*** ./node_modules/__pika__/react.js ***!
\****************************************/
/*! no static exports found */
/***/ (function(module, exports) {
module.exports = window["https://cdn.skypack.dev/react"];
/***/ })
}]);Although CDN‑based ESM loading reduces bundle time, it can cause exponential network requests, long white‑screen periods, and compatibility issues when many internal packages lack ESM builds.
Moving to Unbundled Development
Tools like es‑dev‑server, Snowpack, WMR, and Vite abandon bundling entirely: the dev server watches source files, compiles them on demand, and serves native ESM to the browser. Their advantages include:
Lightning‑fast startup because only changed modules are compiled.
Real‑time on‑demand compilation for each import request.
Faster hot‑module replacement (HMR) by updating only affected modules.
Our team selected Vite 2.0 as the foundation for a custom dev server, extending it with internal plugins to meet specific needs such as deep dependency preprocessing, custom CSS handling, and unified configuration.
Dependency Pre‑processing
Many internal libraries only provide CJS builds. We first convert them to ESM using an internal CJS‑to‑ESM service, cache the results locally, and then run esbuild to bundle the converted files, dramatically reducing the number of network requests.
{
"react?16.14.0": "/Library/Caches/__web_modules__/[email protected]",
"react-dom?16.14.0": "/Library/Caches/__web_modules__/[email protected]",
"object-assign?4.1.1": "/Library/Caches/__web_modules__/[email protected]"
}During the esbuild build we resolve these paths via an onResolve plugin:
const bundleResult = await require('esbuild').build({
entryPoints: ['react', 'react-dom'],
bundle: true,
splitting: true,
chunkNames: 'chunks/[name]-[hash]',
metafile: true,
outdir: webModulesDir,
format: 'esm',
treeShaking: 'ignore-annotations',
plugins: [{
name: 'resolve-deps-plugin',
setup(build) {
build.onResolve({ filter: /^/ }, async args => {
const { kind, path } = args;
if (['import-statement', 'entry-point', 'dynamic-import'].includes(kind)) {
// Resolve using the JSON map above
}
});
}
}]
});The resulting bundleResult.metafile yields an import‑map.json that the dev server uses to rewrite bare imports to the cached ESM files (e.g., /node_modules/.web_modules/react.js ).
Resource Handling
JSX/TSX files are transformed with esbuild.transform :
const result = await esbuild.transform(code, {
loader: 'tsx',
sourcefile: importer,
sourcemap: true,
target: 'chrome63'
});When using React 17, we inject import React from 'react' because esbuild lacks automatic JSX runtime support. For const enum usage we fall back to the TypeScript API, and for projects that rely on Babel plugins (e.g., babel-plugin-macros ) we run an additional Babel step.
After transformation, bare imports are rewritten to point at the generated ESM files:
import React from "/node_modules/.web_modules/react.js";CSS, JSON, and image assets are also served as JavaScript modules. CSS is injected via a generated style element:
const code = "body {\n margin: 0;\n}\n";
const styleEl = document.createElement('style');
styleEl.type = 'text/css';
styleEl.appendChild(document.createTextNode(code));
document.head.appendChild(styleEl);JSON modules simply export a default object:
export default { "name": "example" };Images are imported with a ?assets query, and the dev server returns the URL (or a base64‑encoded string for small files):
import logo from './logo.png'; // rewrites to '/src/logo.png?assets'
export default '/src/logo.png';Hot Module Replacement (HMR)
In a Webpack‑style setup the entry file registers module.hot.accept . For unbundled tools we expose import.meta.hot following the ESM‑HMR spec:
import.meta.hot.accept();
import.meta.hot.accept(['./dep1', './dep2'], () => {});When a dependency changes, the server walks the import graph, invalidates caches for affected modules, and re‑requests them with a timestamp to ensure the browser receives the latest version. This approach is faster than full‑bundle HMR and can be combined with React Fast Refresh for component‑level updates.
Conclusion
By replacing the monolithic Webpack build with a custom unbundled dev server built on Vite‑like plugins, we achieved dramatically faster startup times, more responsive HMR, and a flexible architecture that can later be extended for SSR/SSG. Production still uses Webpack for final bundling, but the dev experience is now aligned with modern ESM‑first tooling, and the internal CJS‑to‑ESM service provides a reusable solution for future projects.
ByteFE
Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.
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.