A bundler starts from an entry file, follows every import, transforms what the browser cannot run as is (TypeScript, JSX, CSS imports, images) and writes a few optimized files. Native ES modules work, but an unbundled app loads hundreds of files in a slow chain of requests.
What it does: resolves bare imports such as 'react' to files in node_modules, transpiles, drops unused exports (tree shaking), minifies, splits the code into chunks and adds a content hash to each file name for caching.
The waterfall: the browser sees a module's imports only after downloading and parsing it, so an import chain ten levels deep costs ten round trips. A bundle flattens the chain.
Request count: lodash-es alone is over 600 modules. HTTP/2 makes each request cheaper, but it removes neither the per-module overhead nor the waterfall.
Development is different: Vite serves unbundled modules in dev, where requests to localhost are cheap and anything the current page does not import is skipped.
ReactMiddle
Frontend Tooling & Build
Bundlers, Vite and webpack, esbuild, SWC and Babel, tree shaking, Fast Refresh, source maps, environment variables, browser targets and polyfills, npm, semver and lockfiles, linting, supply-chain safety
Questions closed
0 / 18
- 0Review
- 5Asked often
Questions
Vite reads .env files at build time and replaces every import.meta.env.VITE_* reference with its literal value in the JavaScript it outputs. Every visitor downloads that JavaScript, so anything with the VITE_ prefix is public; variables without the prefix never reach client code.
Files: .env for every mode, .env.production or .env.staging for one mode (vite build --mode staging), .local variants for values kept out of git. A variable already set in the shell wins over all of them.
The prefix is a guard, not encryption: it stops server variables from leaking by accident. Search the built bundle in DevTools and the key is there.
What may go in: values that are public by design, such as a Stripe publishable key, a Sentry DSN or a maps key restricted to your domain. A secret key stays on a server, and the browser calls your backend, which adds it.
One build per environment: values are baked in, so promoting one build from staging to production needs config loaded at runtime, such as a /config.json.
Next.js does the same with NEXT_PUBLIC_, and Create React App did it with REACT_APP_.
dependencies are packages your code needs when it runs, devDependencies are needed only to build, test and lint it, and peerDependencies are packages a library expects the host app to provide, so that the app and the library share one copy.
What gets installed: in your project npm install installs the first two kinds. When your package is installed into another project, only its dependencies come along; on a Node server npm ci --omit=dev skips the dev ones.
In a bundled frontend app the split matters less: Vite bundles whatever is imported, wherever it is listed.
Peers are for libraries: a React component library lists react as a peer. Listed in dependencies, it could install a second copy of React, and hooks then fail with "Invalid hook call".
npm 7 and later install peers automatically and stop with ERESOLVE on a version conflict; --legacy-peer-deps hides the conflict instead of fixing it.
**optionalDependencies** may fail without failing the whole install; esbuild uses them to ship one native binary per platform.
^1.2.3 accepts any 1.x.x from 1.2.3 up (new minor and patch versions), ~1.2.3 only 1.2.x patches. The lockfile records the exact version and checksum of every package in the tree, and npm ci installs exactly that, so every machine and every build gets the same code.
Semver: major for breaking changes, minor for features, patch for fixes. It is a convention, not a guarantee; a patch release can still break you.
Below 1.0.0 the caret is stricter: ^0.2.3 means >=0.2.3 <0.3.0, because in 0.x a minor bump counts as breaking.
Without a lockfile, two installs a week apart resolve the same ranges to different versions, including deep transitive packages you never listed.
**npm install** resolves ranges and may rewrite package-lock.json; **npm ci** deletes node_modules, installs exactly the lockfile and fails if it does not match package.json.
Commit it (package-lock.json, pnpm-lock.yaml, yarn.lock or bun.lock), read its diff in reviews, and update on purpose through Renovate or Dependabot PRs.
In development Vite does not bundle your code: it pre-bundles dependencies once and serves your source as native ES modules, transforming each file when the browser requests it. webpack bundles the whole dependency graph before it serves the first page, so its start time grows with the app.
Development
Dependency pre-bundling: packages from node_modules are converted to ESM and merged into a few files (CommonJS needs converting, and lodash-es would otherwise mean 600 requests), then cached in node_modules/.vite. Rolldown does this since Vite 8, esbuild did before.
Source on demand: a .tsx file is transformed when it is requested, so a route you never open costs nothing.
HMR: after an edit only the changed module is sent again, so updates stay fast as the app grows.
The limit: a page that imports thousands of modules makes thousands of requests on its first load, and barrel files make it worse.
Production
vite build bundles with Rolldown (Rollup before Vite 8), tree-shakes, splits chunks, minifies and hashes the file names.
Dev serves separate modules while the build bundles them, so a few bugs (CommonJS interop, module evaluation order) show up only in the build. Run vite build && vite preview before a release.
HMR swaps an edited module into the running page without a reload: the dev server pushes the change over a WebSocket, and the nearest module that accepts updates applies it. Fast Refresh is React's layer on top: it re-renders the edited components with the new code and keeps their state.
Boundaries: an update climbs the import graph until a module accepts it (import.meta.hot.accept in Vite, module.hot in webpack). If it reaches the entry first, the page reloads.
How React does it: the React plugin (a Babel, SWC or Oxc transform) registers every component in a file and makes files that export only components accept their own updates. React then swaps the function behind each component and keeps its useState and useRef values.
State resets when you add, remove or reorder hooks in the component, for class components, in a file marked // @refresh reset, and after you fix an error that crashed rendering.
Effects run again: in an edited component each refresh re-runs useEffect, useMemo and useCallback whatever their dependencies, so an effect that subscribes without a cleanup piles up listeners.
Full reloads come from files that export something besides components and from edits to plain modules that only the entry imports. HMR exists only in development.
The file exports the ThemeContext object and the useTheme hook next to the component, so Fast Refresh cannot update it in place. Vite logs "Could not Fast Refresh ("ThemeContext" export is incompatible)" and passes the update up to main.tsx, which cannot accept it, so the page reloads.
Why the exports matter: a module can take a hot update only if, after it runs again, every export is a component or still the same value. createContext returns a new object and useTheme is a new function, so both count as changed; a string or number constant stays equal and is fine.
Where the reload comes from: Vite hands the update to the module's importers. main.tsx imports ThemeProvider and has no HMR handler, so Vite falls back to a full page reload and every piece of state is lost.
The fix: move the context and the hook into themeContext.ts. ThemeProvider.tsx then exports only a component, and editing it hot-updates the page with the form intact.
Lint catches it: ESLint's react-refresh/only-export-components and Oxlint's react/only-export-components (on in the current Vite React template) report "Fast refresh only works when a file only exports components".
A loader transforms one file at a time as webpack imports it, for example TypeScript to JavaScript or Sass to CSS. A plugin hooks into the compilation and works on the build as a whole: it generates the HTML page, extracts CSS into files, defines constants or analyses the bundle.
Loaders live in module.rules: test picks the files, use lists the loaders, and they run from right to left, so ['style-loader', 'css-loader', 'sass-loader'] compiles the Sass first. A loader is essentially (source) => newSource.
Plugins are classes with an apply(compiler) method that tap webpack's lifecycle hooks: HtmlWebpackPlugin, MiniCssExtractPlugin, DefinePlugin, ModuleFederationPlugin.
Rule of thumb: work on one file's content is a loader; anything about chunks, assets, output files or the environment is a plugin.
Assets: webpack 5 handles images and fonts with built-in asset modules (type: 'asset'), which replaced file-loader and url-loader.
Vite and Rollup have no loaders: a plugin's transform(code, id) hook does a loader's job, and other hooks (resolveId, load, generateBundle) cover the rest.
Rspack implements the same API in Rust, so many webpack configs move to it with few changes.
Transpiling rewrites one file's syntax into another form (TypeScript and JSX to plain JavaScript, new syntax to older); bundling follows imports across files and combines the results into chunks. Babel, SWC and esbuild transpile without checking types; only tsc type-checks.
Babel: written in JavaScript, the slowest of them, with the largest plugin ecosystem; the React Compiler ships as a Babel plugin.
SWC: a Rust transpiler, used by Next.js and by @vitejs/plugin-react-swc.
esbuild: a bundler and transpiler written in Go; Vite used it for transforms and dependency pre-bundling until Vite 8 replaced it with Oxc and Rolldown.
**tsc**: in a bundled app it runs with noEmit as a separate check. TypeScript 7 (2026) rewrote it in Go, about ten times faster, but its job stayed the same.
**So vite build succeeds with type errors**: the Vite template's build script is tsc -b && vite build, and CI runs the type check as its own step.
A per-file transpiler sees one file: it cannot tell whether a re-exported name is a type, keeps the name, and the browser fails with "does not provide an export named 'User'". isolatedModules (implied by verbatimModuleSyntax) makes tsc reject that code up front.
Only the unused parseCsv from ./utils and the unused parts of lodash-es are removed. lodash ships whole because it is CommonJS, every helper in dates stays because dates[helper] can reach any of them, and ./setupI18n stays because it is imported for its side effect.
How it works: ES module imports and exports are static, so the bundler marks the used exports and drops code nothing reaches, if removing it cannot change behaviour.
**lodash**: its CommonJS build attaches every function to one object at runtime (module.exports = _), which the bundler cannot analyse, so the whole library ships (about 70 KB minified). From lodash-es only throttle and the small internal helpers it imports stay.
**import * as dates**: a namespace import shakes fine with static access such as dates.short, but a computed key may be any export, so all 40 helpers stay.
**./setupI18n**: an import with no bindings exists for its side effects and is always kept. A package that wrongly declares "sideEffects": false gets such imports, CSS files included, silently dropped; list them instead: "sideEffects": ["*.css"].
Also in the way: top-level calls such as export const theme = createTheme() (kept unless marked /* @__PURE__ */), a Babel config that turns import into require, and dev mode, which never tree-shakes.
The hash is computed from the file's content, so a file keeps its URL until its content changes and gets a new one when it does. Servers can then cache assets/* forever, while index.html, the one file that points at the current hashes, is revalidated on every visit.
Headers: Cache-Control: public, max-age=31536000, immutable for hashed assets, no-cache for index.html. After a release only changed files are downloaded again, while a cached index.html would keep users on the old release.
Imported assets: import logoUrl from './logo.svg' gives a hashed URL such as /assets/logo-<hash>.svg, files under 4 KB (build.assetsInlineLimit) are inlined as data URLs, and a missing file fails the build instead of returning a 404 in production.
**public/**: copied as is to the root of dist, never hashed or processed, referenced by an absolute path. It is for files that need a fixed name: robots.txt, favicon.ico, the web manifest, an Open Graph image that other sites link to.
The deploy trap: a tab opened before a release asks for chunks the new deploy has deleted and gets "Failed to fetch dynamically imported module". Keep the previous release's assets on the server for a while, and reload on Vite's vite:preloadError event.
A source map is a .map file that maps each position in the minified bundle back to the original file, line and name, so DevTools and error trackers can show your real code. In production the usual choice is to generate them, upload them to the error tracker and not serve them publicly.
How the browser finds one: a //# sourceMappingURL=app.js.map comment at the end of the bundle, or a SourceMap response header; DevTools downloads the map only when it is open.
What is inside: mappings (positions encoded as Base64 VLQ), the sources paths and usually sourcesContent, the full original source with its comments.
Public maps: anyone can read your unminified code, comments and internal names. The minified code is public anyway, so this is not a security hole by itself (a secret in frontend code is exposed either way), but it makes leaking internals and copying easier.
Hidden maps: build.sourcemap: 'hidden' in Vite (hidden-source-map in webpack) writes the files without the comment; CI uploads them to Sentry or Datadog for this release and deletes them. Stack traces become readable instead of index-a1b2.js:1:48213, and users get no maps.
Cost: maps add build time and memory on large apps, so preview builds can skip them.
Decide from your users' analytics and the business, write the decision down as a browserslist query, and let the tools work from it: transpilers rewrite newer syntax for those browsers, and polyfills add the APIs they lack. A transpiler alone cannot add a missing function.
Deciding: real traffic per browser version (Safari on iPhone updates only with iOS), customers on locked-down corporate machines, and the bytes every extra old browser adds for all users.
**browserslist** in package.json or .browserslistrc is one list read by Babel, Autoprefixer and @vitejs/plugin-legacy: defaults, > 0.5%, last 2 versions, not dead, or since 2025 baseline widely available.
Syntax (?., ??, class fields) can be rewritten into older JavaScript. APIs (Object.groupBy, structuredClone, Promise.withResolvers) can only be added by a polyfill, usually core-js, which Babel's preset-env injects per use with useBuiltIns: 'usage'.
Vite lowers syntax to its own build.target list (by default baseline-widely-available: Chrome 111, Firefox 114, Safari 16.4) and adds no polyfills. @vitejs/plugin-legacy builds an extra bundle with polyfills for older browsers.
Check it: eslint-plugin-compat flags APIs outside your targets, and a real device at the bottom of the range catches the rest.
All three install from the npm registry; they differ in how they lay out node_modules. npm and Yarn Classic hoist packages into one flat folder, while pnpm links them from a shared store, so code reaches only what it declares. A phantom dependency is a package you import without declaring it.
How phantoms happen: with hoisting, import debug from 'debug' works because some package you use depends on it. When that package drops it or moves to another major version, your build breaks without any change to your package.json.
npm: ships with Node, writes package-lock.json, hoists into a flat node_modules, has workspaces since npm 7.
pnpm: keeps every version once in a content-addressable store, hard-links it into node_modules/.pnpm and symlinks only your direct dependencies. Installs are fast, disk space is shared, phantom imports fail, and dependency install scripts run only once you approve them. It is a common monorepo choice.
Yarn: Classic (v1) works like npm and is in maintenance mode; Yarn 4 defaults to Plug'n'Play, a .pnp.cjs resolution map instead of node_modules, strict like pnpm but needing extra setup for some tools.
One per repo: pin it with the packageManager field in package.json and commit only its lockfile. Bun is a fourth, npm-compatible option with bun.lock.
TypeScript checks that types fit; ESLint checks patterns that type-check fine but are bugs or bad practice, such as a hook called conditionally, a missing effect dependency or a promise nobody awaits. Prettier only formats. Each tool has one job, and CI runs all three as separate checks.
ESLint finds: the React hooks rules (rules-of-hooks, exhaustive-deps), accessibility (jsx-a11y), import boundaries, react-refresh/only-export-components, and with type information no-floating-promises and no-misused-promises. Typed rules need the TypeScript program (projectService: true) and are slower.
TypeScript finds: wrong types, missing props, a null you did not handle; a linter cannot see those.
Prettier formats: it rewrites the layout, so keep style rules out of ESLint (or add eslint-config-prettier) instead of letting two tools fight over commas.
In CI: prettier --check ., eslint . --max-warnings=0 and tsc --noEmit (or tsc -b) as parallel jobs; locally, editor integration plus lint-staged on changed files in a pre-commit hook.
Faster linters: Oxlint (Rust, now in the Vite templates) and Biome (lint plus format) are many times faster; teams often keep ESLint only for the plugins those lack.
ESLint 10 (2026) supports only the flat eslint.config.js format.
exports is a package's public entry map: every importable path and, per condition (types, import, require, default), the file it resolves to; anything unlisted cannot be imported. Most ESM and CommonJS breakage is a file whose real format differs from what the consumer or its types expect.
What exports does
Encapsulation: once exports exists, import 'pkg/dist/internal.js' fails with ERR_PACKAGE_PATH_NOT_EXPORTED, so adding it to a published package is a breaking change.
Conditions match in object order and the first match wins: put types first and default last. Bundlers add their own, such as browser and development.
It takes precedence over main and module in Node and in modern bundlers.
Why imports break
**The format comes from the extension and "type"**: with "type": "module" every .js file is ESM, so a CommonJS build saved as .js fails with "require is not defined in ES module scope"; it has to be .cjs.
ESM importing CommonJS gets module.exports as the default export; named imports work only when Node can detect them statically, otherwise "Named export 'x' not found".
CommonJS requiring ESM threw ERR_REQUIRE_ESM until Node 20.19 and 22.12 enabled require(esm); a module with top-level await still cannot be required.
Dual package hazard: a package that ships both formats can load twice in one app, once per format, with two copies of its state, so a context or an instanceof check stops matching.
Types: TypeScript reads exports only with moduleResolution set to bundler, node16 or nodenext, and ESM types served for a CommonJS file mistype its default import.
What helps
Give each format its own types (.d.ts and .d.cts), as in the example, and check the package with publint and @arethetypeswrong/cli before publishing.
Many new packages ship ESM only, since every maintained Node version can require() it.
Measure before changing anything: time each step on its own and profile the bundler. The cause is usually a slow transform on every file (often Babel), type checking or linting inside the build, huge barrel imports, an old toolchain or a cold cache in CI.
Find the cause
Split the pipeline: run tsc --noEmit, the linter and the bundler separately and time each. A "ten-minute build" is often a type check or lint plugin (fork-ts-checker-webpack-plugin, vite-plugin-checker) running inside it.
Vite: vite --profile records a .cpuprofile to open in speedscope, vite --debug plugin-transform shows the time per plugin and file, and vite-plugin-inspect shows what each plugin does.
webpack: webpack --profile --json > stats.json or Rsdoctor shows the time per loader and plugin.
Slow dev start: "new dependencies optimized" in the log followed by a reload means dependencies were discovered late and forced a second optimization; list them in optimizeDeps.include.
Fix
Native transforms: swc-loader or esbuild-loader instead of babel-loader, @vitejs/plugin-react v6 (Oxc) or plugin-react-swc; limit the remaining Babel plugins, such as the React Compiler, to the files that need them.
Fewer modules: import directly instead of through large barrels (an icon set barrel alone can mean thousands of modules), and warm up common files with server.warmup.
Caches: webpack 5 cache: { type: 'filesystem' }, tsc -b with its .tsbuildinfo, and in CI restore the package manager store and these caches between runs.
Upgrade: Vite 8 with Rolldown, Rspack in place of webpack, Turbopack (the default in Next.js 16), the native TypeScript 7 compiler for the type check.
CI layout: type check, lint, tests and build as parallel jobs; in a monorepo, build only affected packages (Turborepo or Nx with a remote cache). Track build time as a CI metric so it does not creep back.
Treat every dependency as code that can be hijacked: install only what the lockfile pins, wait before adopting brand-new versions, block install scripts, keep secrets out of reach of the install, and keep the dependency list short. No single measure is enough.
What 2025 showed
In September a phished maintainer account published malicious versions of chalk, debug and 16 other packages. The code hooked fetch and crypto wallet APIs in the browser to swap payment addresses, so apps built in those hours shipped it to their users.
The Shai-Hulud worm (September, with a second wave in November) ran from install scripts, stole npm, GitHub and cloud tokens from developer machines and CI, and used them to publish infected versions of more packages.
npm then revoked all classic tokens and moved publishing to short-lived tokens, 2FA and trusted publishing from CI.
At install time
Lockfile only: npm ci or pnpm install --frozen-lockfile in CI, and read lockfile diffs in review: an unexpected new package or a changed resolved URL is a red flag.
Cooldown: malicious versions are usually caught within hours or days, so skip versions younger than that: pnpm minimumReleaseAge (a day by default since pnpm 11), npm min-release-age (in days, npm 11.10+), a cooldown in Renovate or Dependabot.
No install scripts by default: pnpm runs dependency scripts only for approved packages (allowBuilds); npm 12 blocks them unless the root package.json lists them in allowScripts; on npm 11 set ignore-scripts=true.
Limit the damage
Secrets: no publish tokens or cloud credentials in the install step, short-lived OIDC credentials in CI, no long-lived token in ~/.npmrc.
Detection: npm audit knows only reported CVEs; behaviour scanners such as Socket flag a new install script, obfuscated code or network access in an update.
Fewer packages: every dependency, and its maintainer's account, is part of your trust chain.
In the browser: a strict CSP connect-src limits where injected code can send data, and Subresource Integrity pins scripts loaded from a CDN.