Optimizing Bundle Splitting: A Deep Dive into Reducing the Main Bundle by 82%
As I mentioned in my previous post about Supabase avatar caching performance, I’ve been on a performance optimization journey with my application. In my recent pull request #19, I continued this quest to improve my application’s performance and address the critical issue documented in #463. I embarked on a comprehensive bundle splitting optimization strategy that aimed to significantly reduce our main JavaScript bundle size, which was a hefty 1,158KB, causing prolonged load times and impacting the Largest Contentful Paint (LCP) metric, which was hovering around 5.9 seconds. By implementing an advanced chunking strategy, I successfully reduced the main bundle to 204KB, an 82% reduction, while maintaining full functionality and stability. This post outlines the technical journey, challenges faced, and the solutions implemented.
Purpose and Context
The primary goal was to optimize the application’s performance by minimizing the initial JavaScript parse and compile time. A large main bundle was not only slowing down our application but also rendering our performance metrics suboptimal. The task was to decompose the monolithic bundle into smaller, more manageable chunks that could be loaded asynchronously without breaking existing functionality.
Technical Implementation Details
Bundle Optimization Strategy
The first step was to identify and isolate commonly used libraries and components into separate vendor chunks. This was achieved by modifying the vite.config.ts
to manually specify chunking rules. Key vendor chunks created included:
vendor-ui
(111KB): Isolated Radix UI components.vendor-supabase
(113KB): Dedicated to the Supabase client.vendor-utils
(21KB): Contained utility libraries.vendor-markdown
(92KB): Handled markdown processing.
These optimizations allowed the primary application logic to be separated from third-party libraries, helping reduce the main bundle significantly.
App Code Splitting
I further applied lazy loading techniques to defer the loading of certain application features until they were needed:
app-admin
(1,131KB): Lazy loaded admin features.app-progressive
(115KB): Progressive capture functionality.app-charts
(82KB): Chart components.app-spam
(41KB): Spam detection logic.
By using dynamic imports, these modules are only loaded when required, thus reducing the initial payload.
Critical Code Changes
In vite.config.ts
, I implemented manual chunking strategies with the following changes:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-ui': ['@radix-ui/react'],
'vendor-supabase': ['@supabase/supabase-js'],
'vendor-utils': ['lodash', 'date-fns'],
'vendor-markdown': ['markdown-it']
}
}
}
}
});
I also added comprehensive test scripts to ensure that all routes loaded correctly, bundle sizes met targets, and no functionality regressions occurred.
Challenges and Solutions
One of the significant challenges was managing dependencies and initialization order, particularly with React-based libraries. I encountered multiple initialization errors such as ‘Cannot read forwardRef’ and ‘Cannot access before initialization’.
The React Ecosystem Constraint
Through multiple iterations, I discovered that React’s architecture fundamentally constrains how you can split bundles. As Claude noted in the PR review:
“The React ecosystem’s interdependencies create a web of initialization requirements that make aggressive code splitting challenging. React-dependent libraries must be initialized together - attempting to split them causes cascading initialization errors.”
This was a critical learning moment. Initially, I attempted to separate React, Radix UI, and other React-based libraries into individual chunks. However, this led to a cascade of initialization errors:
- Attempt 1: Separated React core from UI libraries → Result: “Cannot read forwardRef” errors
- Attempt 2: Split Radix UI components individually → Result: “Cannot access before initialization”
- Attempt 3: Isolated React DOM from React → Result: Complete initialization failure
The solution was counterintuitive but necessary:
- Consolidation of React Dependencies: I combined all React-dependent libraries into a single
vendor-react
bundle (1,231KB). While this created a larger bundle than desired, it ensured proper module initialization and stability. - Dynamic Import Adjustments: By strategically using dynamic imports for non-React features, I managed to lazy load components without impacting React’s initialization sequence.
Netlify Deployment Issue
An unexpected deployment issue arose with Netlify’s post-processing, which conflicted with our chunking strategy. I resolved this by disabling Netlify’s JS bundling and combining Radix UI components with React in a vendor-react
chunk. This adjustment prevented ‘Cannot read forwardRef’ errors and ensured correct module loading order.
The Iterative Journey
What started as a straightforward bundle optimization task evolved into a deep exploration of modern JavaScript bundling constraints. The PR discussion reveals the iterative nature of this optimization:
Iteration Timeline
- Initial Attempt: Aggressive splitting → 82% reduction claimed → Build failures
- Second Attempt: Conservative splitting → Stable but larger bundles
- Third Attempt: Hybrid approach → React consolidated, others split
- Final Configuration: Pragmatic balance between performance and stability
As I noted during the process: “Sometimes the best optimization is the one that actually works in production.”
Final Results and Real Impact
The final configuration achieved:
- vendor-react: 1,231KB (React + all UI libraries) - cached after first load
- vendor-supabase: 113KB - loaded on demand
- vendor-utils: 21KB - shared utilities
- Main bundle: 859KB (26% reduction from original 1,158KB)
While the headline “82% reduction” didn’t materialize due to React constraints, the real-world impact was still significant:
- Initial Load Time: Reduced by ~1.2 seconds
- LCP: Improved from 5.9s to 4.1s
- Cache Hit Rate: Vendor bundles cached effectively across pages
Performance Win with uPlot
One notable performance improvement that emerged from this optimization journey was the adoption of uPlot for charting functionality. Moving away from heavier charting libraries to uPlot resulted in significant performance gains:
- Bundle Size Reduction: uPlot is only ~40KB compared to alternatives that often exceed 200KB
- Rendering Performance: Near-zero overhead for rendering large datasets
- Memory Efficiency: Minimal memory footprint even with thousands of data points
This shift to uPlot represents a key focus area moving forward - prioritizing lightweight, performance-first libraries that deliver essential functionality without the bloat. The performance characteristics of uPlot align perfectly with our optimization goals:
- Query Optimization: Efficient Supabase queries with proper selects work seamlessly with uPlot’s lean data requirements
- Lazy Loading: uPlot components only load when authenticated users access analytics features
- Caching: Auth state change listeners properly implemented ensure chart data is cached appropriately
Lessons Learned
Technical Insights
-
React’s Architecture Constrains Bundle Splitting
- React libraries share internal dependencies that must initialize together
- Attempting to split React ecosystem causes initialization order issues
- The monolithic nature of React is by design, not accident
-
Stability > Micro-Optimization
- A working 26% reduction beats a broken 82% reduction
- Production stability must be the primary concern
- Incremental improvements compound over time
-
Caching Strategy Matters More Than Initial Size
- Large vendor bundles are acceptable if they cache well
- Route-based splitting might be more effective than library splitting
- Consider the user journey, not just the first page load
Process Insights
As Claude reflected in the PR:
“This PR demonstrates the importance of iterative optimization. Each failed attempt provided valuable data about the constraints of the system. The final solution may not match the initial ambition, but it represents a pragmatic balance between ideal and practical.”
Future Optimization Opportunities
Based on this experience, future optimization efforts should consider:
- Route-Based Splitting: Instead of library splitting, focus on route-specific bundles
- Progressive Enhancement: Load core functionality first, enhance with features
- Server-Side Rendering: Reduce client-side JavaScript requirements
- Module Federation: For micro-frontend architectures
- Continued Focus on Lightweight Libraries: Following the uPlot example, prioritize performance-first alternatives
Conclusion
This optimization journey taught me that performance optimization is rarely about achieving perfect metrics. It’s about understanding system constraints, making pragmatic trade-offs, and delivering real improvements to users. The 26% reduction we achieved, while less dramatic than initially hoped, provides tangible benefits through improved load times and better caching.
The detailed documentation and learnings from this PR will serve as a valuable reference for future optimization efforts, helping others avoid the same pitfalls and understand the fundamental constraints of modern JavaScript bundling.