Bundle Analysis & Dependency Auditing
Using @next/bundle-analyzer, source-map-explorer, and size-limit to find bundle bloat — plus npm audit, knip, and npm ls to manage dependency security, duplicates, and dead weight.

Overview
Bundle analysis and dependency auditing are two distinct practices that together ensure your production JavaScript is lean and secure.
Bundle analysis lets you visualize exactly what's in your production output — which packages take the most space, what's duplicated, and where you can cut weight to improve load performance.
Dependency auditing is about the packages themselves: are they vulnerable? Maintained? Are you accidentally shipping five slightly different versions of the same utility?
Neither is a one-time task. Performance regresses silently as features are added. Vulnerabilities appear in packages that were clean six months ago. Both practices belong in your regular development cycle — and the most impactful checks should run in CI on every pull request.
How It Works
Bundle Analysis
A production build merges and tree-shakes your source files and node_modules into output chunks. A bundle analyzer intercepts this — either via a plugin or a generated stats file — and produces an interactive treemap showing the size of every module inside every chunk.
The treemap answers: "Why is my main chunk 900KB? Is lodash being imported as a whole instead of individual methods? Is moment.js still in there even though I replaced it?"
Dependency Auditing
npm audit cross-references your installed packages against the GitHub Advisory Database and reports known CVEs by severity. Separate from security, tools like knip statically analyze your codebase to find packages listed in package.json that are never imported — dead weight slowing installs and expanding your attack surface. npm ls reveals duplicate packages: multiple versions of the same library installed simultaneously, inflating your bundle with nearly identical code.
Code Examples
@next/bundle-analyzer — Interactive Treemap
npm install --save-dev @next/bundle-analyzer// next.config.ts
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true",
});
const nextConfig: NextConfig = {
// your existing config
};
export default withBundleAnalyzer(nextConfig);# Open the interactive treemap — two tabs: client bundle + server bundle
ANALYZE=true next buildWhat to look for in the treemap:
- Unexpectedly large rectangles — a package taking more space than expected
- Duplicate rectangles — the same package appearing in multiple chunks (deduplication issue)
node_modulesdominating — more third-party code than app codemoment/lodashat full size — these have lightweight alternatives (date-fns,lodash-es)
source-map-explorer — Chunk-Level Inspection
source-map-explorer reads source maps and shows exactly which source file contributed how many bytes to each chunk. More granular than the treemap:
npm install --save-dev source-map-explorer// package.json
{
"scripts": {
"analyze:sourcemap": "source-map-explorer '.next/static/chunks/*.js'"
}
}next build # generates chunks with source maps (ensure sourceMaps: true in next.config.ts)
npm run analyze:sourcemap// next.config.ts — enable source maps for analysis builds
const nextConfig: NextConfig = {
productionBrowserSourceMaps: true, // enables .map files for client chunks
};Disable productionBrowserSourceMaps for actual production deployments —
source maps expose your source code. Enable only for analysis builds and strip
them before deploying.
size-limit — Enforce Bundle Budgets in CI
size-limit integrates with CI and fails the build when your bundle exceeds defined limits. This prevents bundle regressions from silently shipping:
npm install --save-dev size-limit @size-limit/file// package.json
{
"scripts": {
"size": "size-limit",
"build": "next build"
},
"size-limit": [
{
"path": ".next/static/chunks/main-*.js",
"limit": "80 KB",
"gzip": true
},
{
"path": ".next/static/chunks/pages/**/*.js",
"limit": "50 KB",
"gzip": true
}
]
}# .github/workflows/bundle-size.yml — CI enforcement
name: Bundle Size
on: [pull_request]
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run build
- run: npm run size
# size-limit exits non-zero if any limit is exceeded → CI failsThe andresz1/size-limit-action GitHub Action posts size diffs as PR comments
— developers see exactly how much their change added to the bundle, in the PR
review workflow.
npm audit — Security Vulnerability Detection
# Summary of vulnerabilities by severity
npm audit
# Only show high and critical — low/moderate often don't affect your usage
npm audit --audit-level=high
# Auto-fix vulnerabilities with safe semver-compatible updates
npm audit fix
# Fix even if it requires a major version bump — review diff before committing
npm audit fix --force# .github/workflows/security.yml — fail CI on high/critical vulnerabilities
- name: Security audit
run: npm audit --audit-level=high
# Exits non-zero on high+ severity → CI fails and blocks mergenpm audit fix --force can silently upgrade packages past breaking changes.
Always review package-lock.json changes and run your test suite before
committing.
knip — Finding Unused Dependencies
npm install --save-dev knip// package.json
{
"scripts": { "knip": "knip" }
}// knip.json — configure entry points and ignore patterns
{
"entry": ["src/index.ts", "app/**/*.{ts,tsx}"],
"project": ["src/**/*.{ts,tsx}", "app/**/*.{ts,tsx}"],
"ignore": ["**/*.test.ts", "**/*.spec.ts"],
"ignoreDependencies": [
"@types/node", // used by TypeScript but not explicitly imported
],
}npm run knip
# Output example:
# Unused dependencies (2)
# moment
# react-spring
# Unused devDependencies (1)
# ts-jest
# Unused exports (4)
# src/utils/legacy.ts: formatLegacyDate, parseLegacyDateVerify each finding manually — knip produces false positives for packages used in config files, Babel plugins, or require() calls in scripts.
npm ls — Detecting Duplicate Package Versions
Multiple versions of the same package installed simultaneously inflate your bundle with nearly identical code. npm ls reveals them:
# See all installed versions of a specific package
npm ls react
# Output:
# my-app@1.0.0
# ├── react@18.3.1
# └── some-legacy-lib@2.1.0
# └── react@16.14.0 ← duplicate — two React versions!
# Find all packages with multiple versions
npm ls --json | npx jq '[.. | objects | select(.version) | .name] | group_by(.) | map(select(length > 1)) | .[]'Fix duplicates with overrides (npm) or resolutions (pnpm/yarn):
// package.json — force all packages to use the same React version
{
"overrides": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}
// pnpm-workspace.yaml equivalent — pnpm uses pnpm.overrides// pnpm package.json equivalent
{
"pnpm": {
"overrides": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}
}Multiple React versions is a particularly serious duplicate — it causes "Invalid hook call" runtime errors because hooks rely on the React instance being a singleton.
Replacing Heavy Dependencies
// ❌ moment — 72KB gzipped, includes all locales by default
import moment from "moment";
const formatted = moment(isoString).format("MMM D, YYYY");
// ✅ date-fns — only the imported functions are bundled (~3KB for these two)
import { format, parseISO } from "date-fns";
const formatted = format(parseISO(isoString), "MMM d, yyyy");// ❌ Full lodash — ~70KB, CommonJS, cannot be tree-shaken as a whole
import _ from "lodash";
const grouped = _.groupBy(orders, "status");
// ✅ lodash-es — ESM, tree-shakeable
import { groupBy } from "lodash-es";
const grouped = groupBy(orders, "status");
// ✅ Or: individual lodash functions — smallest possible import
import groupBy from "lodash/groupBy";
const grouped = groupBy(orders, "status");Checking a Package Before Installing
# Check bundle size impact before adding a dependency
npx bundlephobia date-fns
# Or use the CLI tool for a broader snapshot
npx cost-of-modules --less --no-installThis habit prevents "just adding a small utility" from secretly importing 200KB of transitive dependencies.
Real-World Use Case
A SaaS dashboard's Lighthouse performance score drops from 91 to 63 after six months of feature work. The bundle analyzer reveals two problems: react-data-grid (340KB) is loaded on every page but used only on one admin page, and lodash appears twice in different chunks at slightly different versions.
Fix 1: lazy-load the data grid:
// app/admin/reports/page.tsx
import dynamic from "next/dynamic";
const DataGrid = dynamic(() => import("react-data-grid"), {
loading: () => <p>Loading grid…</p>,
ssr: false,
});Fix 2: force lodash deduplication:
// package.json
{ "overrides": { "lodash": "^4.17.21" } }After npm install and rebuild, the main bundle drops 280KB and the Lighthouse score recovers.
Common Mistakes / Gotchas
1. Importing entire libraries instead of specific functions.
// ❌ Imports all of lodash (~70KB)
import _ from "lodash";
// ✅ Imports only groupBy (~3KB)
import groupBy from "lodash/groupBy";2. Ignoring the server bundle. @next/bundle-analyzer opens both client and server tabs. Bloated server bundles slow serverless cold starts — especially on Vercel or AWS Lambda where init latency is billed.
3. Running npm audit and ignoring "moderate" severities. Triage practically: fix critical and high immediately, schedule moderate for the next sprint, document accepted low risk. Never let the list grow silently — run it in CI on every PR.
4. Not re-running npm audit after npm install. Vulnerability databases update daily. A package clean six months ago may now have a CVE. Run audit in CI on every pull request, not just when adding dependencies.
5. Treating knip output as absolute truth. Static analysis produces false positives for packages used only in config files, Babel plugins, or dynamic require() calls. Always verify manually before uninstalling.
Summary
Bundle analysis reveals what's in your production JavaScript and where the weight is — the essential first step before any optimization. @next/bundle-analyzer provides the treemap; source-map-explorer provides file-level attribution. size-limit enforces budgets in CI, blocking regressions before they ship. npm audit --audit-level=high in CI catches security vulnerabilities on every pull request. knip finds unused packages; npm ls finds duplicate versions. The two practices complement each other: analysis tells you what's big, auditing tells you what's risky or redundant. Run both regularly — performance and security degrade silently without them.
Interview Questions
Q1. How do you find out which package is causing a bundle to be unexpectedly large?
Run ANALYZE=true next build to open the @next/bundle-analyzer treemap. Large rectangles in the treemap indicate large modules. Click into a chunk to see its module breakdown — look for node_modules entries that are disproportionately large. For more granularity, use source-map-explorer '.next/static/chunks/*.js' to see file-level attribution — it shows exactly how many bytes each source file contributed. If a specific package is suspected, search for it in the treemap by name. Common culprits: moment (72KB, includes all locales), lodash (70KB, often imported as a whole), @sentry/browser (40KB+), icon libraries included as full icon sets.
Q2. What is size-limit and how does it prevent bundle regressions?
size-limit is a CLI tool that measures the gzipped size of your output files and compares them against defined limits in package.json. If any file exceeds its limit, size-limit exits with a non-zero code — failing the CI build. This prevents the "slow creep" where each PR adds a few KB until the bundle is double its original size. The andresz1/size-limit-action GitHub Action extends this by posting a PR comment showing the size diff — developers see the bundle impact of their change during review, before merge. The limits should be set based on the current bundle size plus a small margin, and reviewed periodically as the app grows.
Q3. What does npm ls react reveal and why does having two React versions cause runtime errors?
npm ls react shows all installed versions of React across the entire dependency tree — your app's version and any version required by dependencies. Multiple React versions are problematic because React's hooks (and Context) work by storing state in a singleton — a global React instance. When a component from a library uses a different React instance than your app code, hooks like useState look up state in the wrong instance and throw "Invalid hook call." This is a runtime error, not a build error — it appears only when the library component actually renders. Fix with overrides in package.json to force all packages to resolve to the same React version.
Q4. How do you safely fix a security vulnerability found by npm audit?
First, read the advisory — understand what the vulnerability is and whether your usage is actually affected (many CVEs require specific usage patterns you may not exercise). For critical and high: run npm audit fix to apply safe semver-compatible updates. If no safe update exists, run npm audit fix --force cautiously — review the package-lock.json diff to understand what major versions changed, then run your full test suite to catch breaking changes. If a dependency can't be safely updated and your usage is genuinely safe, document the accepted risk in a code comment or security policy. For CI: use --audit-level=high to fail only on critical/high — avoid blocking deployments on low-severity noise.
Q5. What is knip and how does it differ from depcheck?
Both tools find unused dependencies by static analysis, but knip is more accurate for modern TypeScript and monorepo setups. depcheck was designed for simple package structures and misses many modern patterns (TypeScript path aliases, dynamic imports, framework-specific conventions). knip understands TypeScript's module resolution, Next.js conventions, monorepo workspace patterns, and can identify unused exports within your own codebase — not just unused dependencies. Both produce false positives (packages used in config files, Babel plugins, or implicit runtime dependencies). The workflow: run knip, manually verify each finding, then remove confirmed dead packages.
Q6. What is the overrides field in package.json and when should you use it?
overrides (npm v8+, pnpm.overrides in pnpm, resolutions in Yarn) forces all packages in the dependency tree to resolve a specific package to a specific version — regardless of what each package's own package.json specifies. Use it when: (1) a transitive dependency has a security vulnerability and the direct dependency hasn't released a fix yet — override to the patched version; (2) multiple versions of the same package are installed (detected via npm ls) causing runtime errors (multiple React instances) or bundle bloat — force deduplication to one version; (3) a peer dependency conflict causes npm to install duplicate versions when one would work. Treat overrides as temporary technical debt — monitor for when the upstream dependency releases a fix so you can remove the override.
Code Splitting Strategies
Route-level, component-level, and vendor splitting — how next/dynamic and React.lazy work, webpack magic comments for chunk naming and prefetch, loading.tsx integration, and when splitting hurts more than it helps.
Preload, Prefetch & Priority Hints
The full set of browser resource hints — preload, prefetch, preconnect, dns-prefetch, modulepreload — plus fetchpriority on images, Next.js Script loading strategies, and the Speculation Rules API for instant page navigations.