A 500KB JavaScript bundle is not inherently bad. A 500KB bundle that loads upfront on every page, blocking interactivity for 2 seconds on mid-range phones, is. Bundle size optimization is not about achieving the smallest possible number — it is about loading the right code at the right time.
1. Audit What You Actually Ship
Before optimizing, measure. A bundle analyzer shows exactly which dependencies consume how much space. You will often find surprises: a date formatting library consuming 70KB when Intl.DateTimeFormat does the job natively, or a utility library where you use 3 functions but ship the entire package.
Use a web-based bundle size analyzer to check individual packages without setting up a full build pipeline. Paste or upload your bundle and get a visual breakdown of what is inside.
2. Replace Heavy Dependencies
Common swaps that save significant kilobytes:
- moment.js (300KB+) → date-fns (tree-shakeable) or native Intl.DateTimeFormat.
- lodash (full import, 70KB) → lodash-es with per-function imports, or native array/object methods.
- axios (14KB) → native fetch() unless you need interceptors or automatic retries.
- classnames (1.3KB) → template literals for simple conditional class strings.
- uuid (4KB) → crypto.randomUUID() (native, available in all modern browsers).
3. Tree Shaking: Verify It Works
Tree shaking eliminates unused exports from your bundle. It works with ES modules (import/export) but fails silently with CommonJS (require). If you import from a package that uses CommonJS internally, the entire module gets bundled.
Verify tree shaking by checking your output bundle. If lodash methods appear after import { debounce } from 'lodash-es', tree shaking is working. If the entire library appears, something broke the ESM chain.
4. Code Splitting by Route
Every major framework supports route-based code splitting. React has lazy() and Suspense. Next.js does it automatically for page routes. Vue has async components. The principle is simple: do not load the settings page code when the user is on the homepage.
5. Lazy Load Below-the-Fold Components
Components not visible on initial page load (modals, tab panels, charts that appear after scrolling) should load on demand. Use dynamic import() triggered by user interaction or Intersection Observer. This keeps the initial bundle lean without reducing functionality.
6. Minify Properly
Modern minifiers (Terser, esbuild, SWC) do more than remove whitespace. They shorten variable names, inline constant expressions, eliminate dead code branches, and compress property access. Ensure your build pipeline runs minification with default settings at minimum — most frameworks handle this automatically in production builds.
7. Monitor Bundle Size in CI
Set a bundle size budget and check it in every pull request. Tools like bundlewatch or size-limit fail your CI build if the bundle exceeds the threshold. This prevents the gradual drift that turns a 200KB bundle into 600KB over 18 months of 'just one more dependency' decisions.
A reasonable starting budget for most SPAs: 150-200KB gzipped for the initial JavaScript bundle. Individual route chunks should stay under 50KB gzipped. Exceed these and investigate before merging.