System-design · Bundle Optimization

Bundle Optimization Interview Questions
With Answers & Code Examples

6 carefully curated Bundle Optimization interview questions with working code examples and real interview gotchas.

Practice Interactively →← All Categories
6 questions3 beginner2 core1 advanced
Q1Beginner

What is tree shaking and what conditions must be met for it to work?

💡 Hint: Dead code elimination via static analysis of ES module imports — requires ESM, no side-effect imports, no CommonJS

Tree shaking is the process of removing unused exports from a bundle by statically analyzing which imports are referenced.

Conditions required:

  • ES Modules (ESM)import/export are statically analyzable. CommonJS require() is dynamic so bundlers can't determine what's used at build time.
  • sideEffects: false in package.json — tells the bundler it's safe to drop any module that isn't imported. Without this, bundlers assume every module might have side effects (e.g., polyfills modifying globals).
  • No dynamic import patternsimport(variable) defeats static analysis.
  • Bundler support — Webpack, Rollup, esbuild, Vite all support tree shaking by default for ESM.
// utils.ts — only 'add' is imported, 'multiply' will be shaken out
export function add(a: number, b: number) { return a + b; }
export function multiply(a: number, b: number) { return a * b; }

// index.ts
import { add } from './utils'; // multiply never bundled
💡 Common mistake: importing from a barrel file (import { Button } from '@ui') can defeat tree shaking if the barrel re-exports from CommonJS modules. Prefer deep imports for large libraries.
Practice this question →
Q2Beginner

What is code splitting and how does dynamic import() enable it?

💡 Hint: Split the bundle into chunks loaded on demand — dynamic import() is the mechanism, React.lazy is the React wrapper

Code splitting divides the JavaScript bundle into smaller chunks that are loaded on demand rather than upfront, reducing initial load time.

How dynamic import() works:

// Without code splitting — everything in one bundle
import HeavyChart from './HeavyChart';

// With code splitting — HeavyChart loaded only when needed
const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <HeavyChart />
    </Suspense>
  );
}

Bundler behaviour: when the bundler (Webpack/Vite/Rollup) sees a dynamic import(), it emits a separate chunk file. That chunk is only downloaded when the import() call executes at runtime.

Common split points:

  • Route-level — each page/route is a separate chunk (most impactful).
  • Heavy library — import('chart.js') only when a chart is rendered.
  • Feature flag — only load premium features for Pro users.
Practice this question →
Q3Core

How do you analyze and diagnose large bundle sizes?

💡 Hint: webpack-bundle-analyzer, Vite's rollup-plugin-visualizer, source-map-explorer — find what is large and why

Tools:

  • webpack-bundle-analyzer — generates an interactive treemap of all modules and their sizes. Run with ANALYZE=true next build.
  • rollup-plugin-visualizer / vite-bundle-visualizer — equivalent for Vite/Rollup.
  • source-map-explorer — analyzes the production source map to show the real size contribution of each module.
  • Bundlephobia — check the size of npm packages before installing them.

Common culprits found during analysis:

  • Moment.js locale files — ~70 kB of locale data bundled even if you only use English. Fix: use date-fns or configure IgnorePlugin to exclude locales.
  • Full lodashimport _ from 'lodash' bundles everything. Fix: import debounce from 'lodash/debounce' or use lodash-es.
  • Barrel file importsimport { X } from '@ui' may pull in the entire library. Fix: deep imports or sideEffects: false.
  • Duplicate packages — two versions of the same package in node_modules due to peer dep conflicts.
Practice this question →
Q4Core

What is vendor chunk splitting and why is it important for caching?

💡 Hint: Separate third-party code (React, lodash) from app code — vendors change rarely so their chunk stays cached across deploys

Vendor chunk splitting separates third-party dependencies (React, ReactDOM, lodash, etc.) from your application code into a dedicated chunk.

Why it matters for caching:

  • Your application code changes on every deploy; vendor libraries change rarely.
  • Without splitting, a single JS file changes on every deploy — users must re-download React with every code push.
  • With splitting, the vendor chunk URL (content-hashed) stays the same across deploys — it's served from the browser cache immediately.
// vite.config.ts
build: {
  rollupOptions: {
    output: {
      manualChunks: {
        'vendor-react': ['react', 'react-dom'],
        'vendor-router': ['react-router-dom'],
        'vendor-query': ['@tanstack/react-query'],
      }
    }
  }
}

Result: users who visited before only need to download the small app chunk on the next deploy, not the large vendor chunk again.

Practice this question →
Q5Advanced

What is the module/nomodule pattern for differential serving?

💡 Hint: Ship modern ESM to capable browsers and legacy transpiled bundle to IE/old browsers — reduces bundle size for majority of users

Differential serving ships two builds: a modern one (ES2020+ with minimal transpilation) and a legacy one (ES5 for old browsers).

<!-- Modern browsers load this (smaller, faster) -->
<script type="module" src="app.modern.js"></script>

<!-- Legacy browsers (IE11) load this; modern browsers ignore it -->
<script nomodule src="app.legacy.js"></script>

Why it matters:

  • Babel transpilation of async/await, optional chaining, nullish coalescing adds ~10–20% to bundle size in polyfills.
  • 95%+ of users are on modern browsers — they get a leaner bundle.
  • IE11 and very old browsers get the bloated legacy bundle — acceptable since they're a tiny minority.

In practice: Vite handles this automatically via @vitejs/plugin-legacy. Next.js's browserslist config controls transpilation targets.

Practice this question →
Q6Beginner

What is lazy loading and how does it apply to images and components?

💡 Hint: Defer loading of off-screen resources until needed — native loading="lazy" for images, React.lazy + Suspense for components

Lazy loading defers downloading a resource until it is needed (user scrolls to it, navigates to a route, or interacts with a feature).

Images — native lazy loading:

<img src="hero.jpg" loading="lazy" alt="Hero" />

The browser only fetches the image when it enters (or is near) the viewport. Supported in all modern browsers. Next.js <Image> adds this automatically.

React components — React.lazy:

const Modal = React.lazy(() => import('./Modal'));

function App() {
  const [open, setOpen] = useState(false);
  return open ? (
    <Suspense fallback={<Spinner />}>
      <Modal />
    </Suspense>
  ) : <button onClick={() => setOpen(true)}>Open</button>;
}

The Modal bundle is only downloaded when the user clicks "Open" — not on initial page load.

Impact: route-level lazy loading is typically the highest-ROI optimization — initial bundle can shrink 40–70% for content-rich apps.

Practice this question →

Other System-design Interview Topics

Rendering StrategiesCore JSType SystemReact FundamentalsFunctionsMicrofrontendsGenericsAsync JSHooksObjectsMonorepoArrays'this' KeywordUtility TypesError HandlingModern JSPerformanceDOM & EventsState ManagementClasses & OOPCaching StrategiesComponent PatternsAdvanced TypesAuthenticationReact RouterFormsAdvanced PatternsFrontend SecurityConcurrent ReactServer ComponentsTestingEcosystemNetwork OptimizationCore Web VitalsBrowser APIs

Ready to practice Bundle Optimization?

Get AI feedback on your answers, predict code output, and fix real bugs.

Start Free Practice →