What is a bundle?
Bundles are essentially just HTML, CSS, and mostly JS files sent by the server to your browser when you open a website.
We write code in TypeScript, use a bunch of libraries (node_modules), and maybe include some images and SVGs. When we build our code, it all gets bundled into plain JavaScript files with the help of bundler tools like Webpack, Turbopack, etc.
Why should bundles be small?
Clearly, bundles are important for optimization. Let’s look at the impact of bundles across a user’s experience:
-
A big bundle means more bytes to download. This gets worse on slow internet (3G/4G).
-
Even after download, a large bundle takes more time to be parsed by the device. On older devices, this becomes even more laggy.
-
At this point, the user is frustrated and the UX sucks.
-
Even for the developer, this increases LCP, TTI, and impacts your app’s SEO.
- LCP (Largest Contentful Paint): The time taken for the app to become visually complete (mostly affected by CSS).
- TTI (Time to Interactive): The time taken for your app to become clickable by the user (mostly affected by JS).
-
Also, for the developer, you are sending more data from your servers to clients, increasing CDN and bandwidth costs.
How do you make the bundle size small?
Here are the best practices that Vercel’s skill uses:
Avoid barrel imports
Never import directly from index.ts files. Try to be selective and import only specific components.
Note: Even when you do this, depending on the library, you might still be importing the entire library. To avoid this, in Next.js at least, you can enable optimizePackageImports.
Conditional module loading
Rather than importing all libraries at the top, you can import them conditionally, only when the user actually needs them.
Bad:
tsimport { hugeFrames } from "./animation-frames"
Good:
tsuseEffect(() => {
if (!enabled) return
import("./animation-frames").then((m) => setFrames(m.frames))
}, [enabled])
Dynamic imports
In Next.js, you can use next/dynamic to import components lazily after the page has rendered.
next/dynamic supports SSR as well.
React lazy loading + Suspense boundaries
Similar to next/dynamic, but use this only on client-side pages.
If you are confused between conditional importing, dynamic imports, and lazy loading like I was, the table below should clear things up.
| Approach | What it is | Bundle size impact | When it loads | SSR support | Best use cases | Avoid when |
|---|---|---|---|---|---|---|
next/dynamic() | Next.js wrapper for dynamically importing components | ✅ Reduces initial JS by splitting into separate chunks | Loads when component is rendered | ✅ Yes (default), or ❌ with { ssr: false } | Large UI components, route-level sections, expensive widgets, optional UI (charts, editors, maps) | Tiny components (too many chunks hurt performance), or critical above-the-fold UI |
Conditionally importing a module (if (...) await import(...)) | Dynamically import any JS module at runtime based on a condition | ✅✅ Best for avoiding heavy libraries in the initial bundle | Only when the condition happens (user action, feature flag, environment) | Usually ❌ (or tricky) if condition depends on browser APIs | Heavy utilities (PDF/Markdown parsers, analytics, Monaco, chart libs), feature-flagged code, on-click logic | Core-path code that always runs anyway |
React.lazy() + <Suspense> | React-native dynamic import for components | ✅ Similar splitting benefit as next/dynamic | When component renders on the client | ❌ In Next App Router, Suspense is more about streaming and loading states | Pure client-side apps, non-Next setups, simple lazy UI chunks | Next.js SSR scenarios where you need SSR control |
Pre-loading
Pre-loading is when we import heavy components based on an event like hovering over a button. This gives the user a perceived faster UX.
To actually reduce bundle size when preloading, it should be combined with a typeof window !== "undefined" check so it only runs on the client.
