4.3 — Dependency diet¶
Module 4 · Lesson 3 · 🟡 Intermediate · ~40 min
What you'll learn¶
- The specific packages that bloat commerce bundles, with measured replacements
- Barrel files: why they defeat tree‑shaking and how to fix them
- Platform APIs that replace whole libraries at zero cost
- A dependency review process that prevents the next 80 KB
The usual offenders¶
Sizes below are minified+gzipped and approximate — always measure in your own build, since tree‑shaking effectiveness varies with your bundler, config, and import style.
| Package | Typical cost | Replacement | Replacement cost |
|---|---|---|---|
moment (+locales) |
~70 KB | Intl.DateTimeFormat / date-fns (targeted) |
0 / ~3 KB |
date-fns (barrel import) |
~60 KB | Deep imports, or Intl |
~3 KB / 0 |
lodash (full) |
~72 KB | lodash-es deep imports, or native |
~2 KB / 0 |
lucide-react (barrel) |
~90 KB | Deep imports / optimizePackageImports |
~4 KB |
react-icons (barrel) |
~40–200 KB | Deep imports (react-icons/fi) or inline SVG |
~1 KB |
axios |
~14 KB | fetch |
0 |
uuid |
~5 KB | crypto.randomUUID() |
0 |
classnames / clsx |
~0.5–1 KB | Keep — it's tiny and useful | — |
query-string |
~8 KB | URLSearchParams |
0 |
js-cookie |
~2 KB | document.cookie helper / cookies() server‑side |
0 |
react-select |
~35 KB | Native <select> + custom styling |
~2 KB |
react-image-gallery |
~38 KB | CSS scroll‑snap + a few lines of JS | ~3 KB |
swiper |
~60–120 KB | CSS scroll‑snap | ~3 KB |
framer-motion |
~50–110 KB | CSS transitions / Web Animations API | 0 |
chart.js / recharts |
~80–160 KB | Server‑rendered SVG, or lazy‑load | 0 initial |
validator |
~30 KB | Targeted regex / Intl / native validation |
~0.5 KB |
| Full i18n runtime + all locales | ~50–90 KB | Server‑render text; ship one locale | ~5 KB |
core-js polyfills |
~40–80 KB | Tighten browserslist |
0 |
None of these are bad libraries. They're the right choice in many apps. The point is that on a commerce critical path, where 200 KB is your entire budget, each one needs to justify itself.
Dates: the biggest easy win¶
Commerce apps format dates in a few places: delivery estimates, order dates, review dates, sale
countdowns. Intl handles all of them, is built into every browser, and costs zero bytes.
// ❌ 70KB for "March 15, 2026"
import moment from 'moment';
moment(date).format('MMMM D, YYYY');
// ❌ Barrel import can pull far more than you use
import { format, formatDistance } from 'date-fns';
// ✅ 0 KB, locale-aware, and correct for every market you sell in
export function formatDate(date: Date | string, locale: string) {
return new Intl.DateTimeFormat(locale, {
year: 'numeric', month: 'long', day: 'numeric',
}).format(new Date(date));
}
// ✅ "in 3 days" — relative time, 0 KB
export function formatRelative(date: Date, locale: string) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const days = Math.round((date.getTime() - Date.now()) / 86_400_000);
return rtf.format(days, 'day');
}
// ✅ Delivery window: "Arrives Mar 15 – Mar 18", 0 KB
export function formatDeliveryWindow(from: Date, to: Date, locale: string) {
return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric' })
.formatRange(from, to);
}
Prices too — Intl.NumberFormat handles every currency and locale convention correctly, which
hand‑rolled formatters famously do not:
// ✅ Correct for 40 markets, 0 KB
const formatters = new Map<string, Intl.NumberFormat>();
export function formatPrice(amountMinor: number, currency: string, locale: string) {
const key = `${locale}:${currency}`;
let fmt = formatters.get(key);
if (!fmt) {
// Cache: constructing Intl formatters is expensive (~0.5-2ms each).
// In a 48-tile PLP that's up to 100ms if you construct per render.
fmt = new Intl.NumberFormat(locale, { style: 'currency', currency });
formatters.set(key, fmt);
}
return fmt.format(amountMinor / 100);
}
The formatter caching above is a real INP fix, not a micro‑optimization. Constructing
Intl.NumberFormatinside a component that renders 48 times per filter change was responsible for ~60 ms of Aurora's PLP interaction latency.
Barrel files: the silent bundle killer¶
A barrel is an index file that re‑exports everything:
// packages/ui/src/index.ts
export * from './button';
export * from './modal';
export * from './carousel';
export * from './date-picker';
export * from './rich-text-editor'; // 60 KB, used on one admin page
// … 60 more
// Your component imports one thing…
import { Button } from '@aurora/ui';
// …but the bundler must evaluate the whole barrel to know what `Button` is.
// With side effects, dynamic re-exports, or a non-ESM build, tree-shaking fails
// and you get all 60 components.
Barrels also slow builds badly: every file importing the barrel forces the bundler to parse the entire module graph behind it. On a large monorepo this is minutes of build time.
Three fixes, in order of preference¶
1. Deep imports (best).
Requires the package to expose subpath exports:
{
"name": "@aurora/ui",
"sideEffects": false,
"exports": {
".": "./dist/index.js",
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js"
}
}
}
"sideEffects": false is the flag that lets the bundler drop unused exports. If your package has
CSS imports, list them explicitly instead of claiming no side effects:
2. optimizePackageImports (good, and zero migration effort).
Next.js rewrites barrel imports into deep imports at build time:
// next.config.ts
const config: NextConfig = {
experimental: {
optimizePackageImports: [
'@aurora/ui',
'lucide-react',
'date-fns',
'lodash-es',
'@headlessui/react',
'react-use',
],
},
};
Many popular packages are optimized by default in recent Next.js versions; adding your own is still worth it. Measure before and after — the effect varies by package structure.
3. ESLint rule (prevention).
// eslint.config.js
export default [{
rules: {
'no-restricted-imports': ['error', {
paths: [
{ name: '@aurora/ui', message: 'Use deep imports: @aurora/ui/button' },
{ name: 'lodash', message: 'Use lodash-es deep imports, or a native equivalent.' },
{ name: 'date-fns', message: 'Use Intl, or deep imports: date-fns/format' },
{ name: 'moment', message: 'Use Intl.DateTimeFormat.' },
],
patterns: [
{ group: ['lucide-react'], message: 'Import icons individually: lucide-react/dist/esm/icons/x' },
],
}],
},
}];
Icons¶
Icon libraries are the most common single source of unexpected bundle weight, because the barrel import pattern is what every README shows.
// ❌ Pulls the whole icon set in many configurations
import { ShoppingCart, Heart, Search } from 'lucide-react';
// ✅ Option A: optimizePackageImports handles it (verify in the analyzer)
// ✅ Option B: your own SVG components — total control, ~300 bytes each,
// and they work in Server Components with no client JS at all
// components/icons/cart.tsx
export function CartIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
aria-hidden="true" {...props}>
<path d="M3 3h2l2.4 12.3a2 2 0 0 0 2 1.7h7.7a2 2 0 0 0 2-1.6L21 8H6" />
<circle cx="9" cy="20" r="1.5" />
<circle cx="18" cy="20" r="1.5" />
</svg>
);
}
For a large icon set, generate components at build time from SVG files (SVGR or a small script), so each icon is a separate module and tree‑shaking works by construction.
Aurora's icon audit: 34 icons used across the site, lucide-react barrel costing 92 KB.
Generated components: 4.1 KB total, and they became Server Components.
Carousels¶
Product galleries and "you may also like" rails don't need a JS carousel library. CSS scroll‑snap does it natively, with real momentum scrolling on touch, keyboard support, and no JS.
// components/product-carousel.tsx — Server Component, 0 KB of JS
export function ProductCarousel({ products }: { products: Product[] }) {
return (
<div
className="flex snap-x snap-mandatory gap-4 overflow-x-auto scroll-smooth
[scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="region"
aria-label="Recommended products"
>
{products.map((p) => (
<div key={p.id} className="w-[70%] shrink-0 snap-start sm:w-[45%] lg:w-[23%]">
<ProductCard product={p} />
</div>
))}
</div>
);
}
If you need arrow buttons, that's ~20 lines of client code operating on scrollBy:
'use client';
export function CarouselControls({ containerRef }: { containerRef: React.RefObject<HTMLDivElement> }) {
const scroll = (dir: 1 | -1) => {
const el = containerRef.current;
if (!el) return;
el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: 'smooth' });
};
return (
<>
<button onClick={() => scroll(-1)} aria-label="Previous"><ChevronLeft /></button>
<button onClick={() => scroll(1)} aria-label="Next"><ChevronRight /></button>
</>
);
}
~3 KB total, versus 60–120 KB for a carousel library, and the scroll behavior is better because it's native.
Animation¶
framer-motion is excellent, and it costs 50–110 KB. On a commerce site, most animations are
fades, slides, and scale effects that CSS does for free on the compositor.
// ❌ 60KB+ for a fade-in
import { motion } from 'framer-motion';
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} />
// ✅ CSS: 0 KB, runs on the compositor, doesn't compete with React for the main thread
<div className="animate-fade-in" />
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
.animate-fade-in { animation: fade-in 300ms ease-out both; }
/* Respect user preference — this is an accessibility requirement, not a nicety */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in { animation: none; }
}
For genuinely complex sequencing, the Web Animations API is built in:
element.animate(
[{ transform: 'translateY(20px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }],
{ duration: 300, easing: 'cubic-bezier(0.4, 0, 0.2, 1)', fill: 'both' },
);
Keep an animation library if you have shared‑element transitions or gesture‑driven interactions that genuinely need it — but load it only on the routes that use it.
Internationalization¶
Full i18n runtimes ship a message parser, a plural‑rules engine, and often every locale's messages. For a Next.js app with Server Components, most of that is unnecessary.
// ✅ Translate on the server: the client receives strings, not a translation runtime
// lib/i18n.ts
import 'server-only';
import { cache } from 'react';
export const getMessages = cache(async (locale: string) => {
// Only this locale's messages are ever loaded, on the server
return (await import(`@/messages/${locale}.json`)).default;
});
// app/[locale]/p/[slug]/page.tsx
export default async function ProductPage({ params }) {
const { locale, slug } = await params;
const [t, product] = await Promise.all([getMessages(locale), getProduct(slug)]);
return (
<>
<h1>{product.name}</h1>
<AddToCartButton label={t.pdp.addToCart} /> {/* string, not a t() function */}
</>
);
}
Client components that genuinely need runtime translation get only the keys they use, passed as
props. When you truly need a client‑side t() (dynamic strings, client‑only flows), ship a minimal
implementation:
// ~400 bytes, handles interpolation and simple plurals
export function interpolate(template: string, vars: Record<string, string | number>) {
return template.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? ''));
}
export const plural = (n: number, locale: string, forms: Record<string, string>) =>
forms[new Intl.PluralRules(locale).select(n)] ?? forms.other;
Polyfills¶
Check what your build is transpiling for. Targeting old browsers costs bytes for users who don't exist.
// package.json
{
"browserslist": ["chrome >= 111", "safari >= 16.4", "firefox >= 111", "edge >= 111", "not dead"]
}
# What does your current browserslist actually include?
npx browserslist
npx browserslist --coverage # % of global users covered
Check your own analytics for the real distribution before tightening. On a commerce site, the answer is usually that <0.3% of revenue comes from browsers older than 2 years, and supporting them costs every other user 40–80 KB.
Next.js also ships a legacy polyfill bundle for older browsers. Verify it isn't being served to
modern ones — in the Network panel, look for a polyfills-*.js chunk and check whether modern
browsers download it.
The dependency review process¶
Prevention beats cleanup. Three controls:
1. A size gate on package.json changes:
# .github/workflows/dep-review.yml
on:
pull_request:
paths: ['package.json', 'package-lock.json']
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
# Fails if any route exceeds its budget (see 1.5)
- run: npm run build && node scripts/check-bundle-budget.mjs
- name: Require justification
run: |
echo "::notice::New dependency added. PR description must include:"
echo " - Bundle cost (client, gzipped)"
echo " - Alternatives considered (including native APIs)"
echo " - Whether it can be server-only or lazy-loaded"
2. A PR template question:
### New dependency checklist (delete if none added)
- [ ] Measured client bundle cost: ____ KB gzipped
- [ ] Considered a platform API alternative (Intl, URLSearchParams, crypto, WAAPI)
- [ ] Can it be server-only? (`import 'server-only'`)
- [ ] Can it be lazy-loaded behind an interaction?
- [ ] Does it support tree-shaking? (ESM + `sideEffects: false`)
- [ ] Maintenance: last release date, open critical issues
3. A quarterly audit:
npx depcheck # unused dependencies
npm ls --depth=0 | wc -l # direct dependency count over time
npx npm-why <package> # who pulls in a transitive dependency
Aurora's results¶
| Change | Before | After | Saved |
|---|---|---|---|
moment → Intl |
70 KB | 0 | −70 KB |
lucide-react barrel → generated SVG components |
92 KB | 4 KB | −88 KB |
@aurora/ui barrel → deep imports |
74 KB | 22 KB | −52 KB |
swiper → CSS scroll‑snap |
71 KB | 3 KB | −68 KB |
framer-motion → CSS animations |
58 KB | 0 | −58 KB |
| i18n runtime → server translation | 47 KB | 6 KB | −41 KB |
axios → fetch |
14 KB | 0 | −14 KB |
browserslist tightened |
52 KB polyfills | 0 | −52 KB |
| Total | −443 KB |
Six weeks of work by one engineer, with no feature changes and no visual regressions. INP p75 dropped 130 ms as a side effect of the reduced parse/compile and hydration cost.
Common mistakes¶
| Mistake | Cost |
|---|---|
| Barrel imports | Whole libraries in the bundle |
| Assuming tree‑shaking works | It fails silently with CJS, side effects, or dynamic re‑exports |
| Reaching for a library before checking the platform | 30–100 KB for something Intl does |
Constructing Intl formatters per render |
Real INP cost on list pages |
| Keeping a library for one function | Copy the function |
Loose browserslist |
40–80 KB of polyfills for nobody |
| Adding dependencies without a size check | Death by a thousand 8 KB additions |
| Never auditing | Unused dependencies accumulate for years |
Lab 4.3 — Dependency audit¶
- From your bundle analysis (4.1), list every client package over 15 KB.
- For each: check this lesson's table, check whether a platform API covers it, and check whether it could be server‑only.
- Fix barrels first — usually the largest single win, and it's a config change plus find/replace.
- Add
optimizePackageImportsfor anything you can't restructure. Measure the delta. - Add the ESLint
no-restricted-importsrules so the barrels don't come back. - Check
browserslistand your real browser distribution. - Run
npx depcheckand remove what's unused. - Add the dependency PR checklist and the size gate.
Checklist¶
- No barrel imports from large packages
-
optimizePackageImportsconfigured for what remains -
sideEffectsdeclared correctly in internal packages - Dates and prices via
Intl, with cached formatters - Icons as individual modules or inline SVG, not a barrel
- Carousels via CSS scroll‑snap
- Animations via CSS/WAAPI on the critical path
- i18n resolved server‑side
-
browserslistmatches your real user base - ESLint rules prevent regressions
- Dependency additions require a measured justification
Next: 4.4 Hydration cost