interviewDeck

Your one-stop platform to prepare, practice and ace your interviews.

Loading your questions…

All Questions

Filters & tools

Build Tools Interview Questions and Answers

20 hand-picked Build Tools interview questions with detailed answers. Open the interactive version above to search, filter by difficulty, run code, bookmark questions and track your progress.

Why do we bundle JavaScript at all?

Historically: browsers had no module system, so bundlers gave us import/export and resolved node_modules. Under HTTP/1.1, fewer requests also mattered enormously.

Browsers now support ESM natively and HTTP/2 multiplexes, so "reduce request count" is much weaker. Bundlers stayed because of everything else they do:

  • Dependency resolution — bare specifiers (import x from 'lodash') still aren't resolvable by a browser without an import map.
  • Transformation — TypeScript, JSX, Sass, and syntax down-levelling for target browsers.
  • Optimisation — tree shaking, minification, code splitting, asset hashing.
  • Non-JS assets — importing CSS, images and SVGs as modules.
  • Waterfalls — unbundled deep import chains mean the browser discovers modules level by level, which is slow on a real network.

Why is Vite's dev server so much faster than webpack's?

Different strategies for dev.

webpack builds the entire dependency graph and bundles it before serving anything, so dev start-up scales with project size — minutes on a large app — and every change re-runs part of that pipeline.

Vite serves native ESM in dev: the browser requests modules and Vite transforms each one on demand, so start-up is near-instant regardless of app size. Two supporting decisions make it work:

  • Dependency pre-bundling with esbuildnode_modules are pre-bundled once (esbuild is Go-based and 10–100× faster than JS tooling), which also converts CJS deps to ESM and collapses packages with hundreds of internal modules into one request.
  • HMR over ESM — only the changed module is invalidated, so hot updates stay fast as the app grows.

For production, Vite bundles with Rollup — because unbundled ESM in production means request waterfalls and no shared chunking. So the answer is "unbundled in dev, bundled in prod".

Explain webpack's core concepts.

  • Entry — where the dependency graph starts. Multiple entries produce multiple bundles.
  • Output — where files go and how they're named. [contenthash] in the filename is what enables long-term caching.
  • Loaders — transform individual files as they're added to the graph: babel-loader, css-loader, ts-loader. They run right to left in an array, which surprises people.
  • Plugins — hook into the whole build lifecycle for things loaders can't do: HtmlWebpackPlugin, MiniCssExtractPlugin, DefinePlugin.
  • Modedevelopment vs production, which switches on minification, tree shaking and other defaults.
  • optimization.splitChunks — how shared and vendor code is factored out into separate chunks.
  • resolve — extensions and path aliases.

The one-line mental model: loaders transform files, plugins transform the build.

ESM vs CommonJS — why does this still cause problems?

CommonJSESM
Syntaxrequire / module.exportsimport / export
ResolutionRuntime, dynamicStatic, at parse time
LoadingSynchronousAsynchronous
Tree shakingNoYes
Top-level awaitNoYes

Why it hurts: ESM can import CJS (usually), but CJS cannot synchronously require ESM — that's the source of the familiar "require() of ES Module not supported" error. Packages that ship both formats can end up loaded twice in one process (the dual package hazard), so instanceof checks and module-level singletons break.

What controls it: "type": "module" in package.json, .mjs/.cjs extensions, and the exports field's conditional entry points.

Transpiling vs polyfilling, and how do you decide targets?

Transpiling rewrites syntax the target can't parse — arrow functions, optional chaining, class fields. Polyfilling adds missing APIs at runtime — Promise, Array.prototype.flat, fetch. Syntax can't be polyfilled and APIs can't be transpiled, which is why you need both.

Targets come from browserslist — one config in package.json that Babel, SWC, Vite, esbuild, Autoprefixer and Lightning CSS all read, so your JS, CSS prefixes and polyfills stay consistent.

The lever that matters: targeting older browsers produces larger, slower code. > 0.5%, last 2 versions, not dead is a sane modern default; dropping IE11-era targets can cut bundle size dramatically. Check your analytics rather than guessing.

Tooling: Babel is the most configurable; SWC (Rust) and esbuild (Go) are far faster and now the default in Next.js and Vite respectively. Use core-js with useBuiltIns: 'usage' so only the polyfills your code actually needs are included.

How do source maps work, and should you ship them?

A source map is a JSON file mapping positions in the built output back to your original source, so DevTools and error trackers show real filenames, line numbers and code instead of minified soup.

Types trade build speed against fidelity: eval-cheap-module-source-map is fast and good enough for dev; source-map is a separate, complete file for production.

Production options:

  • Don't generate — smallest, but production stack traces become unreadable.
  • Generate and upload to your error tracker only (Sentry, Datadog) — the recommended default: readable traces for you, nothing exposed to users.
  • Generate and serve publicly — fine for open source, but it publishes your original source, including comments and any accidentally-embedded logic you'd rather not explain.

Note the map is only fetched when DevTools is open, so serving one doesn't slow real users down — the concern is disclosure, not performance.

Which package.json fields control how a package is consumed?

  • main — legacy CJS entry point.
  • module — unofficial but widely honoured ESM entry; bundlers prefer it so they can tree-shake.
  • types — TypeScript declarations.
  • exports — the modern replacement for all of the above. It defines conditional entry points (import vs require, node vs browser, types) and, importantly, it encapsulates the package: consumers can no longer deep-import pkg/dist/internal/thing unless you list it. Adding exports to an existing package is a breaking change for anyone doing that.
  • sideEffectsfalse tells bundlers unused modules are safe to drop, which is what makes tree shaking effective. Use an array for files that do have side effects (CSS imports, polyfills).
  • files — what actually gets published; without it you can ship your whole repo.
  • engines, peerDependencies — supported runtimes, and deps the host must provide (React, Angular).

How do semver ranges and lockfiles interact?

Semver is MAJOR.MINOR.PATCH: major = breaking, minor = new features (backwards compatible), patch = fixes. Ranges in package.json:

  • ^1.2.3 — any 1.x.x ≥ 1.2.3 (the npm default).
  • ~1.2.3 — any 1.2.x ≥ 1.2.3.
  • 1.2.3 — exact.

The lockfile records what was actually installed, for the entire transitive tree, with integrity hashes. Without it, two developers running npm install a week apart get different dependency trees from identical package.json files — the classic "works on my machine".

The rules: commit the lockfile (yes, for libraries too — it protects your CI); use npm ci in CI, which installs exactly the lockfile and fails if it's out of sync with package.json; and never hand-edit it — resolve conflicts by re-running install.

npm vs yarn vs pnpm — what actually differs?

All three read package.json and install dependencies; the difference is how they lay out node_modules.

  • npm / yarn classichoist dependencies to the top level to deduplicate. Side effect: phantom dependencies — your code can import a package you never declared, because it happens to be hoisted. It works locally and breaks when the hoisting changes.
  • pnpm — a global content-addressable store with hard links into a strict, nested node_modules. Result: far less disk usage, faster installs, and only declared dependencies are importable — phantom deps are impossible by construction.
  • yarn berry (PnP) — no node_modules at all; a resolution map plus zipped packages. Fastest and strictest, but some tools still don't cope.
  • bun — very fast, npm-compatible, increasingly viable.

Practical advice: pnpm for monorepos and strictness, npm for maximum compatibility and zero setup friction. Pin the manager with packageManager in package.json so the team doesn't mix lockfiles.

When is a monorepo worth it, and what makes it work?

Worth it when multiple apps share code (a design system, API clients, types), you want atomic cross-project changes in one PR, and you'd rather not version and publish internal packages on every change.

Not worth it when projects are genuinely independent with separate teams and release cadences — you'd be paying tooling complexity for nothing.

What makes it tolerable at scale:

  • Workspaces (npm/pnpm/yarn) — the packages link to each other locally, no publishing.
  • A task graph — Nx or Turborepo understand which projects depend on which, so they build in the right order and in parallel.
  • Affected/changed detection — only test and build what a change actually touches. Without this, CI time grows with the repo and everyone suffers.
  • Remote caching — if CI already built this exact input, replay the output. Often the single biggest CI win.
  • Ownership boundaries — CODEOWNERS and module boundary rules, so "one repo" doesn't mean "anyone edits anything".

ESLint vs Prettier — what does each do?

Prettier is a formatter: it reprints your code to a consistent style (line width, quotes, semicolons, indentation). It has deliberately few options, which is the point — it ends style debates by removing the choices.

ESLint is a linter: it finds problems — unused variables, missing hook dependencies, unsafe patterns, accessibility issues in JSX, forbidden imports. Many rules are auto-fixable.

They must not overlap. Historically ESLint had stylistic rules that fought Prettier; the fix is eslint-config-prettier, which turns those off. Modern setups keep the split clean: Prettier owns formatting, ESLint owns correctness.

Where to run them: in the editor on save (fastest feedback), on staged files via lint-staged in a pre-commit hook, and in CI as the backstop. Running the full lint on every commit gets slow and then gets bypassed.

Note ESLint 9's flat config (eslint.config.js) replaced .eslintrc, and Biome/oxlint are fast Rust alternatives combining both jobs.

How does TypeScript fit into a modern build pipeline?

The key insight: type checking and transpiling are separate jobs, and modern setups split them.

  • esbuild/SWC strip types per-file, extremely fast, and do no type checking at all.
  • tsc --noEmit does the type checking, usually in a separate script and in CI.

So your dev server is fast, and type errors surface in the editor and CI rather than blocking every rebuild. The trade-off is that a build can succeed with type errors — which is exactly why the CI check is mandatory.

Config that matters: isolatedModules (required by per-file transpilers; it forbids constructs like const enum that need whole-program knowledge), verbatimModuleSyntax/import type so type-only imports are erased cleanly, strict: true, and project references plus incremental builds to keep large monorepos checkable.

For libraries, generate .d.ts files with tsc or a bundler plugin — consumers need them.

How does Hot Module Replacement work?

HMR swaps a changed module in the running application without reloading the page, so component state, scroll position and open modals survive an edit.

Mechanism: the dev server watches files; on a change it rebuilds just that module and pushes a message over a WebSocket; a runtime in the page fetches the new module and applies it. It then walks up the import graph looking for a module that accepts the update — if none does, it falls back to a full reload.

Why React/Vue components hot-update so reliably: Fast Refresh (React) and the Vue plugin know how to re-render a component while preserving its hooks state. That's framework-specific machinery, not something HMR gives you for free.

When it falls back to a full reload: editing a file with side effects at module scope, changing a file that exports non-components alongside components, circular dependencies, or edits to config files.

What does a modern CSS build pipeline do?

Stages, roughly in order:

  1. Preprocessing — Sass/Less if used, though native CSS nesting and custom properties have removed much of the need.
  2. PostCSS transformsautoprefixer (vendor prefixes from browserslist), modern-syntax down-levelling, and any custom plugins. Lightning CSS (Rust) increasingly replaces this whole layer and is Vite's default path.
  3. Scoping — CSS Modules (hashed class names per file), CSS-in-JS, or utility classes (Tailwind). All solve the same problem: global class collisions.
  4. Purging — Tailwind's JIT scans your source and emits only the utilities you actually use, which is why a utility framework ships small.
  5. Extraction and minification — pull CSS out of JS into hashed .css files for parallel loading and caching; cssnano/Lightning minifies.
  6. Critical CSS — optionally inline above-the-fold styles to avoid render-blocking.

How does content hashing enable long-term caching?

Name each output file after a hash of its contentsmain.a3f9c2.js. Because the URL changes whenever the content does, you can safely serve it with Cache-Control: max-age=31536000, immutable: a returning user re-downloads only what actually changed, and never revalidates the rest.

The HTML entry point references the hashed files and must itself be uncached, which is what makes the whole scheme work.

The classic failure — cache busting cascades: if module IDs are sequential, adding one module renumbers the others and every chunk hash changes, so users re-download the entire app after a one-line fix. Modern bundlers use deterministic module IDs to prevent this, but it's worth verifying: build twice with a trivial change and check how many hashes moved.

Chunk strategy: separate stable vendor code from frequently-changing app code, so a deploy doesn't invalidate React for everyone.

Build-time vs runtime configuration — why does it matter?

Most frontend tooling inlines environment variables at build timeprocess.env.VITE_API_URL becomes a literal string in the bundle. That means the artifact is environment-specific: you cannot build once and promote the same bundle from staging to production, which is exactly what a good deployment pipeline wants to do.

Options for runtime config:

  • Fetch a config endpoint on boot — flexible, but adds a request before the app can start.
  • Inject into the HTML at serve time (a window.__CONFIG__ script or meta tags) — no extra round trip, works with a server or edge function.
  • A generated config.js written at container start-up from environment variables and loaded before the app — the common Docker/Kubernetes pattern.

Either way: anything reaching the browser is public, so runtime config is for URLs, feature flags and public keys — never secrets.

What belongs in a frontend CI pipeline?

Ordered fastest-failing first, so feedback is quick:

  1. Installnpm ci with the dependency cache restored.
  2. Lint and format check — seconds.
  3. Type checktsc --noEmit.
  4. Unit and component tests — parallelised, with coverage thresholds if you use them.
  5. Build — proves the production pipeline works, not just the dev server.
  6. Bundle size check against the budget, reported as a delta on the PR.
  7. E2E tests against the built app (Playwright/Cypress), usually the slowest stage.
  8. Accessibility and Lighthouse on a preview deployment.
  9. Preview deploy with a URL commented on the PR — the highest-value thing for reviewers.

What keeps it usable: cache node_modules and build caches aggressively; run independent jobs in parallel; use affected-only builds in a monorepo; and treat flaky E2E tests as bugs — a pipeline people re-run reflexively stops being a gate.

How do you version and release a shared frontend library?

Follow semver strictly — for a component library, a breaking change includes removing or renaming a prop, changing default behaviour, and often changing DOM structure or class names that consumers style against.

Automation options:

  • Changesets — contributors add a small markdown file describing the change and its bump level; release automation aggregates them into a version bump and changelog. Works especially well in monorepos with several publishable packages.
  • semantic-release — derives the version from Conventional Commit messages. Fully automatic, but only as accurate as commit discipline.

Practices that make consumers happy: publish next/canary tags for early testing; deprecate before removing (a console warning for one minor version); ship codemods for large breaking changes; keep a real changelog with migration notes; and document the supported range of peer dependencies.

How do you debug a build that works locally but fails in CI?

Work through the differences systematically — it's almost always one of these:

  1. Dependency drift — you ran npm install locally weeks ago; CI installs fresh. Delete node_modules, run npm ci locally and reproduce.
  2. Node or package manager version — pin with engines, .nvmrc and packageManager, and check what CI actually uses.
  3. Case-sensitive filesystem — the classic. macOS and Windows are case-insensitive, Linux isn't, so import './Button' resolving to button.tsx works locally and fails in CI.
  4. Environment variables — missing in CI, so a config value becomes undefined and something fails downstream.
  5. Memory — CI containers have less RAM; large builds hit heap limits. Raise --max-old-space-size or reduce parallelism.
  6. Stale cache — a poisoned CI cache. Always try a clean cache run before deeper debugging.
  7. Git-ignored files present locally but never committed.

How would you migrate a large app from webpack to Vite?

Justify it first — the win is developer experience (start-up and HMR), not production output. If dev builds are already fast, the migration may not pay for itself.

Approach:

  1. Inventory the webpack config — every loader, plugin, alias and define. Most have direct Vite equivalents; a few (custom loaders, Module Federation) need real work.
  2. Run Vite alongside — keep webpack as the working build and add a Vite dev script. The team can switch when it's ready, and you can back out at any point.
  3. Fix the predictable breakages: process.envimport.meta.env; require() in app code → ESM; CJS-only dependencies need optimizeDeps.include; require.contextimport.meta.glob; anything relying on webpack-specific magic comments.
  4. Match the production output — chunking strategy, asset paths, base URL, legacy browser support via @vitejs/plugin-legacy.
  5. Verify — compare bundle sizes, run the full E2E suite against the Vite build, and ship behind a canary before switching CI over.