Understand what bundlers and transpilers do, why they exist, and how modern tools like Vite have changed the workflow.

Bishal Biswas
Self Learner
What problem do build tools solve?
Four problems, really.
Too many files. Every import used to be a separate network request. A bundler combines them.
Browsers that lag behind. New syntax is not available everywhere at once. A transpiler rewrites it into older syntax.
Files that are not JavaScript. CSS, images, SVGs and TypeScript all need turning into something a browser understands.
Size. Shipping readable code with comments wastes bandwidth. Minification strips all of it out.
Bundling
// src/maths.js
export const add = (a, b) => a + b;
// src/main.js
import { add } from './maths.js';
console.log(add(2, 3));
A bundler follows the imports from an entry point, builds a graph of every file involved, and writes out one optimised file.
Along the way it can do tree shaking, which means dropping exports nobody imports. This only works with ES modules, because their imports are static and analysable. It is one of the strongest reasons to prefer import over require.
Babel
Babel is a transpiler. It reads modern JavaScript and writes an older equivalent.
const double = (n) => n * 2;
const [a, ...rest] = [1, 2, 3];
class Person {
#secret = 'hidden';
}
Becomes something like:
var double = function (n) {
return n * 2;
};
Configuration lives in babel.config.json:
{
"presets": [
["@babel/preset-env", { "targets": "> 0.5%, last 2 versions, not dead" }]
]
}
preset-env is the clever part. You describe which browsers you support, and it works out exactly which transformations are needed. Support only modern browsers and it does almost nothing, which is the ideal outcome.
Today Babel matters much less. Browsers now support nearly all modern syntax, and tools like esbuild and SWC handle transpiling far faster. You mostly meet Babel in older projects or for framework specific plugins.
Webpack
Webpack was the standard bundler for years. Its central idea is that everything is a module, including CSS and images.
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
clean: true
},
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' },
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{ test: /\.(png|svg|jpg)$/, type: 'asset/resource' }
]
}
};
Three concepts to know:
- Entry is where it starts following imports.
- Loaders transform individual files, chosen by the
testpattern. - Plugins do broader jobs, like generating an HTML file or extracting CSS.
[contenthash] in the filename is important. The hash changes only when the content changes, so browsers can cache the file forever and still pick up updates immediately.
Webpack is powerful and famously fiddly. A configuration can grow to hundreds of lines.
Modern tools
The landscape shifted once browsers gained native ES module support.
Vite serves your source files directly during development with no bundling at all, so the dev server starts instantly no matter how big the project. For production it bundles with Rollup.
npm create vite@latest my-app
cd my-app
npm install
npm run dev
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
server: { port: 3000 },
build: { outDir: 'dist', sourcemap: true }
});
Compare that to the webpack config above. Sensible defaults do most of the work.
esbuild is written in Go and is often ten to a hundred times faster than JavaScript based tools. Vite uses it internally.
Rollup produces very clean output and is the usual choice for publishing a library.
Parcel needs no configuration at all.
For a new project in 2026, Vite is the sensible default.
Hot Module Replacement
HMR swaps a changed module in the running page without a full reload, so your application state survives.
Edit a component, and the page updates while your form stays filled in and your modal stays open. Once you have used it, going back to full reloads feels painful.
Source maps
Minified code is unreadable, so a source map lets DevTools show you the original.
// build: { sourcemap: true }
Use full source maps in development. In production, either upload them privately to your error tracker or use hidden-source-map, so stack traces are readable to you without publishing your source to everyone.
Code splitting
Do not ship the whole application before the first paint.
const button = document.querySelector('#openEditor');
button.addEventListener('click', async () => {
const { Editor } = await import('./editor.js');
new Editor();
});
Every dynamic import() becomes a separate chunk, downloaded only when that line runs. Combine it with route based splitting and your initial bundle stays small.
Do you always need a build step?
No, and that is worth remembering.
<script type="module" src="app.js"></script>
For a small site with a handful of modules and no framework, native ES modules in the browser are perfectly fine. HTTP/2 makes many small requests much cheaper than they used to be.
Add a build step when you need JSX or TypeScript, npm packages, minification, or the developer experience of HMR.
Quick recap
- Bundlers combine modules, transpilers rewrite modern syntax for older browsers.
- Tree shaking removes unused exports, and it needs ES modules.
- Babel matters less now that browsers keep up and faster tools exist.
- Webpack works through entry, loaders and plugins, and is configuration heavy.
- Vite serves modules directly in development and bundles for production.
[contenthash]filenames give you permanent caching that still updates.- Split code with dynamic
import()so the first load stays small.
// ---------- The source you write ----------
// src/maths.js
export const add = (a, b) => a + b;
export const unused = () => 'this gets tree shaken away';
// src/main.js
import { add } from './maths.js';
console.log(add(2, 3));
// A bundler follows every import from the entry point, drops `unused`,
// minifies the result, and writes one file with a content hash.
// ---------- vite.config.js: modern, small, sensible defaults ----------
import { defineConfig } from 'vite';
export default defineConfig({
server: { port: 3000 },
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
// [hash] changes only when the content changes, so browsers can
// cache aggressively and still pick up new builds immediately
entryFileNames: 'assets/[name].[hash].js'
}
}
}
});
// ---------- webpack.config.js: the older, more explicit style ----------
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
clean: true
},
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, use: 'babel-loader' },
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
{ test: /\.(png|svg|jpg)$/, type: 'asset/resource' }
]
}
};
// ---------- Code splitting keeps the first load small ----------
document.querySelector('#openEditor')?.addEventListener('click', async () => {
const { Editor } = await import('./editor.js'); // its own chunk
new Editor();
});
// ---------- And sometimes you need no build step at all ----------
// <script type="module" src="app.js"></script>
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready