first time I saw import './App.css' inside a JavaScript file I thought someone was messing with me. CSS is not JS. the browser cannot import CSS. and yet the app ran, the button was blue, nobody else in the room looked confused. I nodded. I googled it later on the toilet.
a bundler was lying to the browser on my behalf. webpack, vite, parcel — we treat them like weather. the terminal says compiling, we wait, we don't ask why. then one day webpack.config.js is 400 lines and you're the person who has to touch it. this is the article I needed before that day.
three things get mashed together in our heads: ES modules (the language), bundlers (the machine that follows imports), and the specific tool (webpack vs vite). they are not the same. once you split them, the config files stop looking like spells.
forget the bundler. what is an ES module?
JavaScript used to have no import. you dropped <script> tags in order and prayed. file A created a global. file B used it. swap the tags, everything exploded. we called this a codebase.
Node got CommonJS — require() and module.exports. great on servers. browsers still had no idea what require was. so we invented browserify, then webpack, to smash all those requires into one file a <script src> could run. the bundler existed because the browser was behind.
2015: the language grows import and export. that's ES modules. ESM. 2017–2018: browsers actually ship it. this is native now. no webpack required for the syntax itself.
<script type="module" src="./main.js"></script>
// main.js
import { greet } from './greet.js'
greet('yaar')
// greet.js
export function greet(name) {
console.log('hey', name)
}type="module" is the whole trick. the browser fetches main.js, sees the import, fetches greet.js, wires them together. each import is its own HTTP request. two files, two round trips. eighty files, eighty round trips before anything paints. that's the catch nobody mentions in the MDN snippet.
so yes, browsers can do modules now. they still cannot do TypeScript, JSX, CSS imports, SVG-as-a-component, or the 4,000 CommonJS files inside react. native ESM is real. it is not enough for the app you actually ship.
why we still bundle
a bundler starts at one file — usually main.tsx — and follows every import until it has a map of the whole app. people call this the module graph. then it turns that graph into files a browser can run without having a breakdown.
four jobs, always, no matter the logo on the docs:
1. resolve — './Button' → src/Button.tsx (real path on disk)
2. transform — TS / JSX / CSS / images → JS the browser accepts
3. graph — who imports whom, so we don't miss a file
4. emit — write bundle.js (and chunks, CSS, hashed names)webpack, rollup, esbuild, turbopack, vite's production build — same four jobs. they argue about when to do them and what language the tool is written in. that's it. I wasted a year thinking they were different religions.
hit run. watch the requests.
native ESM: the browser walks imports itself. each file is a request. CSS imports and CommonJS react? the browser just stares.
click around up there if you haven't. native ESM means the browser walks the graph itself, request by request. webpack walks the graph on your machine, then the browser downloads one (or a few) files. vite is the weird middle — more on that in a minute.
webpack — how it actually works
webpack wants the whole graph cooked before it serves you a plate. that's the old 'compiling…' spinner. 2017 me thought 40 seconds was normal. it was not normal. we were just used to it.
you point it at an entry. it parses that file, finds imports, resolves them, repeats. every file that enters the graph gets shoved through loaders. when the graph is complete, plugins get a turn at the whole compilation. then webpack writes files to disk. then, and only then, the browser is allowed to see something.
module.exports = {
entry: './src/main.tsx',
output: { filename: 'bundle.[contenthash].js' },
module: {
rules: [
{ test: /\.tsx?$/, use: 'babel-loader' },
{ test: /\.css$/, use: ['style-loader', 'css-loader'] },
],
},
plugins: [
new HtmlWebpackPlugin({ template: './index.html' }),
],
}two arrays. that's the whole personality. rules = loaders. plugins = plugins. people mix them up because both live in the same file and both sound like 'extra stuff'. they are not extra stuff. they are two different moments in the build.
loaders — one file at a time
a loader is a function. file in, different file out. babel-loader takes Button.tsx and returns JS. css-loader takes a .css file and returns a JS module that exports the CSS as a string — yes, a string. it does not put styles on the page. I thought it did for a long time.
style-loader is the one that injects a <style> tag. they chain. webpack runs loaders right-to-left, which is the most webpack sentence ever written. use: ['style-loader', 'css-loader', 'sass-loader'] means: sass first, then css-loader, then style-loader. last in the array runs first. I still count on my fingers.
other loaders you'll see in old configs: ts-loader (TypeScript), file-loader / url-loader (images — webpack 5 just uses asset modules now), raw-loader (file as a string). same idea. match a filename, run a transform, pass the result down the chain.
plugins — the whole compilation
plugins do not look at one .tsx file. they hook into webpack's lifecycle. compilation started. modules built. about to write files. files written. that's why HtmlWebpackPlugin can generate index.html with the right <script src="bundle.a1b2c3.js"> — it waits until webpack knows the hashed filename. a loader cannot do that. a loader only sees its file.
HtmlWebpackPlugin // write index.html, inject script tags
DefinePlugin // replace process.env.NODE_ENV at build time
MiniCssExtractPlugin // pull CSS out of JS into a .css file
CopyWebpackPlugin // chuck favicon.ico into dist/
TerserPlugin // minify JS (usually via optimization.minimizer)DefinePlugin is the one that makes process.env.NODE_ENV === 'production' actually true in the browser bundle. it is not reading your .env at runtime. it find-and-replaces the text while packing. that's why a forgotten DefinePlugin means your 'dead code' for dev still ships.
theme.scss→ sass-loader →theme.csstheme.css→ css-loader →js module (css string)css string→ style-loader →<style> in the DOMdifferent file, same idea
Button.tsx→ babel-loader →Button.jsloaders chain on a single file, right-to-left in the config. sass → css → style. babel on the tsx. they never see the whole app.
vite — they stopped bundling your source in dev
vite's trick is almost rude. browsers already understand ESM. so in development, vite does not glue App.tsx to Button.tsx. it starts a server and waits. the browser asks for /src/main.tsx. vite transforms that one file — strip types, turn JSX into function calls — and sends it back. the browser sees import App from '/src/App.tsx' and asks for that next. no giant graph. no 40-second compile. first request, first transform.
that's why npm create vite@latest feels broken the first time. the server is 'ready' in 300ms. webpack people stare at the terminal waiting for the other shoe.
then why isn't it instant forever?
your source is fine as native ESM. node_modules is not. react, lodash, some UI kit — thousands of tiny files, often CommonJS, often with import chains that would make a browser fetch 200 files to render a button. if vite served those raw you'd hate it more than webpack.
so vite pre-bundles dependencies once with esbuild (written in Go, comically fast) into a few ESM files sitting in node_modules/.vite. your source: on demand. vendor code: packed ahead of time. that's the split. first cold start might take a second while it pre-bundles. after that, cache. you add a new dep, it pre-bundles again, you move on.
production is not the same trick
you do not ship 400 ESM files to real users. HTTP/2 helps. it does not help that much. also you want minification, tree-shaking, code splitting, hashed filenames. so for npm run build, vite uses rollup. now it DOES bundle. same four jobs as webpack. different bundler, because rollup is very good at ESM and tree-shaking.
HMR, without the myth
you save Button.tsx. webpack's old HMR had to figure out which part of the giant bundle changed and patch it — often it just shrugged and reloaded the page. vite already served Button as its own module. it re-transforms that file, pushes it over a websocket, the browser swaps that one module. the rest of the page stays. that's why it feels instant. not magic. smaller unit of work.
the request, side by side
webpack: you hit localhost. webpack is still walking src/. you wait. bundle.js lands. browser downloads it. done-ish.
vite: you hit localhost. server is already up. browser asks for main.tsx. vite runs it through esbuild/transform, returns JS. browser asks for App.tsx, same story. react comes from the pre-bundled .vite cache, one file, not a hundred. you see the page while webpack would still be on 'compiling'.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
// that's the whole file for most apps.
// webpack people: yes. that's it. I'm sorry.what you actually need to remember
ES modules are the import/export syntax. browsers can run them. they will not run your TypeScript, and they will not enjoy fetching your entire node_modules.
a bundler follows imports, transforms unspeakable files, writes something the browser can execute. webpack does this up front, always. vite skips bundling your source in dev, pre-bundles deps with esbuild, and uses rollup when you actually ship.
webpack loaders transform one file. webpack plugins hook the whole build. if you remember only that, you can read a webpack.config.js without crying. maybe a little crying. less crying.
if you remember one thing
the browser learned import. it did not learn JSX, and it will not fetch 4,000 files for you. webpack's answer was: cook everything first, loaders per file, plugins for the kitchen. vite's answer was: serve your source as modules, transform as they ask, pack the vendor stuff once, and still bundle for production. same meal. vite stopped preheating the whole oven just to toast one slice.