Improving Web Application Performance with React and Vite

In this project case study, I'll detail how I used Vite and the S.O.L.I.D. principles to transform a performance-challenged B2B platform into a lightning-fast application serving thousands of users daily.

The goal was to reduce load times to below 2 seconds, eliminate UI lag, and create a responsive experience that matched user expectations for modern SaaS applications.

Client
Fitness
Type
Architecture
Year
2024
Project image

Process

Application architecture

The application was an SPA built in TypeScript with the React framework. The app used React Query with Axios via custom hooks to fetch data and parse props. While slightly opinionated, the MVVM approach is used. MVVM is often used in SwiftUI and is similar to MVM in C#. In React this can be achieved as PCC, "Page", "Container", "Components". Where a given feature or space within the app, uses a page for routing (often times at a higher level), a container for fetching and parsing data; as well as for creating high level component context, and then components for rendering UI. Often times, components can utilize their own hooks for obtaining useful functionality.

Initial performance profiling

Using DataDog, React Scan and Lighthouse, I began to identify render bottlenecks. Via Chrome's developer tools, I noticed a few things right off the bat, there was only one asset representing the client app, the page loader was showing until almost all of the data was fetched, and requests for data that wasn't displayed on the page. I then went on to discover unnecessary re-renders, a large bundle size, and more inefficient data fetching patterns.

Page-based bundle splitting with Rollup via Vite

Creating dynamic imports with React.lazy and Suspense

React.lazy enables dynamic imports of components, loading them only when needed rather than bundling everything upfront:


Suspense provides a loading state while lazy components are being fetched:

jsx<Suspense fallback={<LoadingSpinner />}>
  <HeavyComponent />
</Suspense>

The main bundle only includes code needed for the initial render. This dramatically improves:

  • Time to First Byte (TTFB)

  • First Contentful Paint (FCP)

  • Time to Interactive (TTI)

Determining how to split your bundle

Initial system design allowed for clear visualization of which data was needed on every page. The SasS application was a dashboard, with a fixed number of tabs and pages. While containing a significant amount of dynamic content, the routing was static. For example,

const Dashboard = React.lazy(() => import('./routes/Dashboard'));
const Analytics = React.lazy(() => import('./routes/Analytics'));
const Settings = React.lazy(() => import('./routes/Settings'));

function App() {
  return (
    <Router>
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/analytics" element={<Analytics />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </Router>

This resulted in 10+ chunks that isolated vendor JS, page specific JS, and shared JS.

The application uses MapBox, while being an incredibly powerful tool (check out my Mapping with React and MapBox article) it's quite larger.

After the bundle splitting, it's currently responsible for ~50% of the bundle size. Most noteworthy however, was that the application only utilized MapBox functionality on one specific page.

Each page was now loading <10% of the overall build size.

Configured aggressive progressive loading during app initialization

Isolated API interactions, utilizing async network requests and Promise.All for batching requests. Meticulously ensured that app initialization was completely local to initialization logic and the individual page logic. Considered what was needed at first paint and what could be loaded after first paint, or potentially in the background.

Outcome

Real World Impact

Initial load times went from

3.6s → 1.38s (62% faster)

Lighthouse score went from

42 → 96

First Contentful Paint went from

2.8s → 0.9s

Time to Interactive went from

5.2s → 1.8s

Best Practices

  1. Split at route boundaries first - biggest impact

  2. Split heavy libraries (charts, editors, PDF viewers)

  3. Consider user flow - preload likely next steps

  4. Provide meaningful loading states - skeletons > spinners

  5. Test on slow connections - ensure graceful degradation

  6. Monitor bundle sizes - use webpack-bundle-analyzer or rollup-bundle-visualizer

When Not to Use

  • Components always rendered on initial load

  • Tiny components (< 1KB)

  • Critical above-the-fold content

  • Components loaded in tight loops

React.lazy and Suspense transform large applications from monolithic bundles into efficient, progressively-enhanced experiences that load exactly what users need, when they need it.

Want to connect or create? Drop me an email.

Want to connect or create? Drop me an email.

Want to connect or create? Drop me an email.