#Performance#CSS

How Dynamic CSS Loading Cut My Bundle Size by 85%

Learn how I cut CSS bundle size by 85% and improved page load times by 50% using dynamic imports and automatic code splitting in Next.js—a simple pattern with massive performance impact.

By Pratik Rai

How I cut my CSS bundle size by 85% and improved page load times by implementing dynamic component loading with automatic code splitting—a simple pattern with massive performance impact.

The Problem: Loading Everything, Using Almost Nothing

I built a platform to showcase interactive frontend challenges—image carousels, modal dialogs, memory games, Simon Says, accordions, and more. Each demo was carefully crafted with its own styles and logic. The problem emerged when I looked at my bundle:

Every user was downloading CSS for every demo, regardless of which one they wanted to view.

Here's what our initial bundle looked like when a user visited any demo page:

Initial Page Load:
├── Memory Game styles (style.css)
├── Simon Says styles (style.css)
├── Accordion styles (style.css)
├── Modal Component styles (styles.css)
├── Image Carousel styles (style.css)
├── Tic-Tac-Toe styles (style.css)
├── Whack-A-Mole styles (style.css)
├── Dice Roller styles (style.css)
├── Infinite Scrolling styles (style.css)
└── Nested Comments styles (style.css)
─────────────────────────────────
Total: ~150KB of CSS

The Real Cost

This wasn't just about file size. The cascading effects were worse:

1. Render-Blocking Resources

All CSS had to download and parse before the browser could render anything. On slower connections, users stared at blank screens.

2. Wasted Bandwidth

A user visiting only the Memory Game demo downloaded styles for 9 other components they'd never see. On mobile networks with metered data, this was particularly problematic.

3. Unnecessary Parsing

The browser had to parse thousands of CSS rules that would never apply to any element on the page. This blocked the main thread and delayed interactivity.

4. Poor Core Web Vitals

Our performance metrics showed the impact:

  • First Contentful Paint: 2.5+ seconds
  • Time to Interactive: 4+ seconds
  • Lighthouse Performance Score: Below 70

I needed a better approach.

The Solution: Dynamic Imports with Automatic Code Splitting

The solution came from leveraging a built-in Next.js feature: when you dynamically import a component, Next.js automatically code-splits its CSS into a separate chunk.

No complex webpack configuration. No manual CSS extraction. Just dynamic imports.

The DynamicDemoLoader Component

The key idea is simple: route to “feature module” by slug, then dynamically import that module. Next.js will turn that import boundary into a separate JS chunk — and if the module imports CSS, that CSS becomes a separate CSS chunk too.

Instead of showing a full copy-paste component, here’s the pseudo-structure you want:

TSXcomponent.tsx
1// PSEUDO-CODE (skeleton) 2// Goal: load only the demo the user asked for 3 4const DEMO_COMPONENT_MAP = { 5 "memory-game": () => import(".../MemoryGame"), 6 "simon-says": () => import(".../SimonSays"), 7 "accordion": () => import(".../Accordion"), 8 // ... 9}; 10 11export function DynamicDemoLoader({ slug }) { 12 const load = DEMO_COMPONENT_MAP[slug]; 13 14 if (!load) { 15 return <FallbackUI />; 16 } 17 18 // This creates a split point (JS chunk) and pulls CSS chunk only when needed. 19 const Demo = dynamic(load, { loading: () => <LoadingUI /> }); 20 21 return <Demo />; 22}

Why this reduces CSS

There are two types of CSS in a typical Next.js app:

  • Global/base CSS: globals.css (and shared component CSS) — loaded for every route.
  • Feature CSS: styles imported inside a specific demo module (e.g. MemoryGame/style.css).

When you statically import every demo somewhere in the main tree, the bundler has no choice but to include their CSS in the initial output.

When you dynamically import a demo, Next can keep that demo’s CSS out of the initial payload and fetch it only when the import is executed.

How Each Demo Component is Structured

Each demo should be a self-contained module. That means the demo “owns” its logic and its CSS. In practice, it looks like this:

TSXcomponent.tsx
1// PSEUDO-CODE (module shape) 2"use client"; 3 4import "./style.css"; // demo-specific styles live with the demo 5 6export default function Demo() { 7 // local state + handlers 8 return <div className="demo-root">{/* ... */}</div>; 9}

The Key Pattern

Notice the consistent structure I follow:

  1. "use client" directive for client-side interactivity
  2. CSS import (import './style.css') inside the demo module
  3. Component logic and UI
  4. Default export

This structure is all Next.js needs to automatically extract and code-split the CSS — as long as the demo is behind a dynamic import boundary.

How It Works: The Build Process

When Next.js builds the application, here's what happens:

1. Component Analysis

Next.js analyzes each dynamically imported component and identifies its dependencies—including CSS imports.

2. Automatic CSS Extraction

For each dynamic import, Next.js:

  • Extracts the CSS into a separate file
  • Generates a unique hash based on content
  • Creates a mapping between component and CSS chunk

3. Build Output

The build creates separate chunks for each demo:

.next/static/chunks/
├── main-[hash].js (base app JavaScript)
├── main-[hash].css (base app styles ~20KB)
├── 123-[hash].js (Memory Game component)
├── 123-[hash].css (Memory Game styles)
├── 456-[hash].js (Simon Says component)
├── 456-[hash].css (Simon Says styles)
├── 789-[hash].js (Accordion component)
├── 789-[hash].css (Accordion styles)
└── ... (more demo chunks)

4. Runtime Loading

When a user navigates to /design/memory-game/demo:

1. Initial Page Load
   ├── Browser downloads base HTML
   ├── Fetches main JavaScript (~150KB)
   └── Fetches main CSS (~20KB)
   
2. Page Renders
   └── User sees page structure, header, navigation
   
3. DynamicDemoLoader Executes
   └── Checks slug === 'memory-game'
   
4. Dynamic Import Triggered
   ├── Browser fetches memory-game-[hash].js
   └── Browser fetches memory-game-[hash].css
   
5. Component Renders
   └── Memory Game appears with its styles applied

The loading state provides feedback during the chunk fetch:

TSXcomponent.tsx
1// PSEUDO-CODE: keep it simple, prevent layout shift, reassure the user 2loading: () => <LoadingUI text="Loading demo…" />

The Results: Dramatic Improvements

Bundle Size Reduction

Before Dynamic Loading:

Initial Bundle (any demo page):
├── HTML: ~15KB
├── JavaScript: ~200KB
├── CSS: ~150KB (all demos)
└── Total: ~365KB

After Dynamic Loading:

Initial Bundle:
├── HTML: ~15KB
├── JavaScript: ~150KB
├── CSS: ~20KB (base only)
└── Total: ~185KB

Per-Demo On-Demand:
└── Demo chunk: ~25KB (JS + CSS)

Result: 49% reduction in initial payload

CSS Specifically

The CSS improvement is even more dramatic:

  • Before: 150KB CSS loaded upfront
  • After: 20KB base CSS + 10-15KB per demo on-demand
  • Savings: 85% reduction in initial CSS

Performance Metrics Improvement

The impact on user experience:

MetricBeforeAfterImprovement
Initial CSS150KB20KB87% smaller
First Contentful Paint2.5s1.2s52% faster
Time to Interactive4.2s2.1s50% faster
Lighthouse Score6894+26 points

Real User Scenarios

Scenario 1: User views Memory Game

  • Before: Downloads 365KB, waits 2.5s
  • After: Downloads 185KB + 25KB = 210KB, waits 1.2s
  • Saves: 155KB bandwidth, 1.3s time

Scenario 2: User views three demos

  • Before: Downloads 365KB once
  • After: Downloads 185KB + (25KB × 3) = 260KB
  • Saves: 105KB bandwidth, faster initial load

Scenario 3: User views all ten demos

  • Before: Downloads 365KB
  • After: Downloads 185KB + (25KB × 10) = 435KB
  • Result: Slightly more total, but progressive loading maintains fast perceived performance

The Break-Even Point

Users who visit 7+ demos will download slightly more total data than before. But they get:

  • Instant initial page load
  • Progressive enhancement
  • Better perceived performance
  • Cached chunks for instant revisits

For 90% of users (who view 1-3 demos), this is a massive win.

Integration with the Demo Page

The integration detail that matters most is: only render the demo when the user asked for it.

This sounds obvious, but it’s easy to accidentally violate by putting the demo loader “nearby” in the tree. If the demo renders (even briefly), you can trigger the dynamic import and fetch the demo’s CSS/JS.

TSXcomponent.tsx
1// PSEUDO-CODE (page composition) 2const isCodeView = searchParams.view === "code"; 3 4return ( 5 <> 6 <Header /> 7 {isCodeView ? <CodeViewer /> : <DynamicDemoLoader slug={slug} />} 8 </> 9);

The beauty of this integration:

  • DynamicDemoLoader only renders when view is not 'code'
  • No demo CSS loads when viewing code
  • Seamless transition between preview and code views
  • Server-side rendered shell with client-side enhancement

Why This Pattern Works

1. Automatic CSS Splitting

The magic is that you don’t need custom build tooling. Next already knows how to:

  • Create a split point at a dynamic import boundary (separate JS chunk)
  • Extract CSS imported by that boundary into a separate CSS chunk
  • Load those chunks only when the boundary is executed at runtime

What you need to do is keep the boundary “clean”:

  • Don’t statically import the demo anywhere else
  • Don’t import demo CSS from a shared/global file
  • Keep the demo module self-contained

2. Developer-Friendly

Adding a new demo stays easy because the system is predictable:

TSXcomponent.tsx
1// PSEUDO-CODE 2// 1) Create a demo module + colocated CSS 3import "./style.css"; 4export default function MyNewDemo() { /* ... */ } 5 6// 2) Register it 7DEMO_COMPONENT_MAP["my-new-demo"] = () => import(".../MyNewDemo");

3. Graceful Fallback

Not every slug needs an internal demo on day one. If there’s no mapping, render a friendly “not available” state (and optionally an external demo link).

This allows us to incrementally build demos without breaking the experience.

4. Loading States Improve UX

Dynamic imports are real network requests. A stable loading UI improves perceived performance and avoids layout shift.

5. Server-Side Rendering Compatible

With the App Router, your page shell can stay server-rendered (metadata, header, description, navigation), while the interactive demo is loaded on-demand on the client. That’s usually the best tradeoff for SEO + performance.

Additional Optimizations Enabled

1. Progressive Enhancement

The loading pattern enables true progressive enhancement:

Initial Load (fast):
├── HTML structure
├── Base styles
├── Header/navigation
└── Demo description

Enhanced Load (on-demand):
├── Demo component
├── Demo styles
└── Interactive features

Users can start reading about the demo while the interactive component loads.

2. Better Caching Strategy

With separate chunks, browsers cache more efficiently:

  • Base CSS: Cached across all demo pages (20KB)
  • Each demo CSS: Cached individually (10-15KB)
  • Revisits: Instant load from cache

A user who views Memory Game, then Simon Says, then returns to Memory Game:

  • First Memory Game visit: Downloads chunk (~25KB)
  • Simon Says visit: Downloads different chunk (~25KB)
  • Second Memory Game visit: Instant load from cache (0KB)

3. Reduced Memory Footprint

Only loaded components consume browser memory:

  • Unvisited demos: 0 bytes in memory
  • Visited demos: Cached efficiently
  • Mobile devices: Lower memory pressure

4. Bandwidth Savings for Most Users

Based on my analytics:

  • 60% of users view only 1 demo
  • 25% of users view 2-3 demos
  • 10% of users view 4-6 demos
  • 5% of users view 7+ demos

For 95% of users, dynamic loading saves significant bandwidth.

Implementation Best Practices

Here’s the short checklist that keeps this pattern working over time:

  1. Keep split points clean

    • Don’t statically import demo modules from shared components.
    • Avoid re-exporting demos from a “barrel” index that gets imported globally.
  2. Colocate styles

    • Demo-specific CSS should be imported by the demo module itself (not globals.css).
  3. Use a single registry

    • One map of slug → () => import(...) makes the system auditable.
    • Prefer a type-safe slug union if your slugs are known at build time.
  4. Make loading predictable

    • Use a stable loading placeholder (same container size).
    • Keep it lightweight: no heavy skeleton animations unless measured.
  5. Fail gracefully

    • Missing mapping → show a clear “Demo not available” UI.
  6. Verify with DevTools

    • On first paint, you should only see base CSS/JS.
    • When switching to a demo, you should see a new demo JS chunk + demo CSS chunk.

Lessons Learned

1. Framework Features are Powerful

I initially considered manual webpack configuration. But Next.js dynamic imports with automatic CSS splitting provided everything I needed—with zero configuration.

2. Small Files Add Up Fast

Each demo's CSS seemed reasonable individually (10-20KB). But 10 demos × 15KB = 150KB that most users never needed.

3. Measure Real Impact

Lighthouse scores improved, but I also monitored real user metrics. The improvements were consistent across both lab and field data.

4. Progressive Loading Beats Optimistic Loading

I experimented with preloading likely-to-be-visited demos. Result: minimal benefit, increased complexity. On-demand loading was simpler and more effective.

5. 85% of Users Benefit Significantly

Only power users who view many demos see equivalent total bandwidth. The vast majority get a significantly faster experience.

When to Use This Pattern

This approach works best for:

Multi-feature applications with distinct modules
Demo/documentation sites with multiple examples
Dashboard applications with different sections
Any app where users visit a subset of features

It's less beneficial for:

❌ Single-page apps with uniform functionality
❌ Small applications with minimal CSS (under 50KB total)
❌ Apps where users always visit all sections

Conclusion

My DynamicDemoLoader component demonstrates that strategic code splitting delivers massive performance wins with minimal code:

  • 85% reduction in initial CSS (150KB → 20KB)
  • 50% improvement in Time to Interactive
  • 49% reduction in initial payload
  • 26-point increase in Lighthouse score

The implementation is remarkably simple—about 80 lines of code—yet the impact is substantial.

Key Takeaways

  1. Dynamic imports automatically split CSS in Next.js—no configuration needed
  2. Small files compound quickly into significant overhead
  3. Loading states improve perceived performance during chunk fetches
  4. Progressive enhancement is better than loading everything upfront
  5. Most users benefit significantly even if power users see equivalent bandwidth

If you want to implement this

Start small and keep it measurable:

  1. Pick one heavy feature (a section with lots of JS/CSS) and move it behind a dynamic import.
  2. Colocate feature CSS inside that feature module.
  3. Add a stable loading UI so the user experience doesn’t regress.
  4. Verify in DevTools: initial load should not download the feature’s JS/CSS, and navigation to the feature should.

The web platform gives us powerful optimization tools. By leveraging dynamic imports and automatic code splitting, I can build faster applications that deliver better experiences—especially for users on slower networks and devices.


Want to see the implementation? Check out the DynamicDemoLoader source code and explore how I structure my demo components.

Goal: Understand how dynamic imports with automatic CSS code splitting can dramatically improve performance metrics and user experience.

Related Articles

Continue learning with these related challenges

View All
Blogs

Performance Optimisation from First Principles

Understanding frontend performance optimization from the ground up - learn how browsers work and optimize your code accordingly.

Performance · Frontend · JavaScript · BrowserPratik Rai ·

Blogs

Web Performance: Deep Notes

Core Web Vitals (LCP, INP, CLS), loading strategies (code splitting, tree shaking, resource hints, image optimization), runtime performance (layout thrashing, compositor animations, web workers, virtualization), and a structured debugging framework.

JavaScript · Browser · Web Performance · ReactPratik Rai ·

Blogs

Understanding the Critical Rendering Path

Understand how browsers render pages through the Critical Rendering Path. Learn to optimize DOM, CSSOM, render tree construction, and reduce time to first paint for faster websites.

Critical Rendering Path · Performance · FrontendPratik Rai ·