80% Performance Improvement: SSR, Lazy Loading, and Webpack Optimization

Our client’s website was painfully slow. Initial page load: 8.3 seconds. Time to Interactive: 12.1 seconds. Google PageSpeed score: 23/100.

After systematic optimization with Server-Side Rendering (SSR), lazy loading, and Webpack tuning, we achieved:

  • Initial load: 1.6 seconds (80% improvement)
  • Time to Interactive: 2.4 seconds (80% improvement)
  • PageSpeed score: 94/100

Here’s the complete playbook for how we did it.

The Starting Point

Client: E-commerce site built with Vue.js Problems:

  • Single-page application with client-side rendering only
  • 2.3MB JavaScript bundle
  • All routes and components loaded upfront
  • Unoptimized images and assets
  • No caching strategy

Impact:

  • 68% bounce rate on mobile
  • Low SEO rankings (Googlebot timeout)
  • Poor conversion rates

The Strategy

We tackled performance in layers:

  1. Server-Side Rendering (Nuxt.js migration)
  2. Code Splitting (route and component-level)
  3. Lazy Loading (images, components, routes)
  4. Webpack Optimization (bundle size, caching)
  5. Asset Optimization (images, fonts)

Part 1: Server-Side Rendering with Nuxt.js

Why SSR?

Client-side rendering sends empty HTML, then loads JavaScript, then renders content:

<!-- What Googlebot sees with CSR -->
<html>
  <body>
    <div id="app"></div>
    <script src="app.js"></script> <!-- 2.3MB! -->
  </body>
</html>

Server-Side Rendering sends fully rendered HTML:

<!-- What Googlebot sees with SSR -->
<html>
  <body>
    <div id="app">
      <header>...</header>
      <main>
        <h1>Product Title</h1>
        <img src="product.jpg" alt="Product">
        <button>Add to Cart</button>
      </main>
    </div>
    <script src="app.js"></script>
  </body>
</html>

Benefits:

  • Faster First Contentful Paint (FCP)
  • Better SEO (content visible to crawlers)
  • Improved perceived performance

Migration to Nuxt.js

Nuxt.js is a framework for Vue.js that provides SSR out of the box.

Before (Vue CLI):

src/
  components/
  router/
  store/
  views/
  App.vue
  main.js

After (Nuxt.js):

pages/          # Auto-generates routes
  index.vue     # → /
  products/
    _id.vue     # → /products/:id
components/     # Auto-imported components
store/          # Vuex store modules
nuxt.config.js  # SSR configuration

Key changes:

  1. asyncData for server-side data fetching:
<!-- Before: Client-side only -->
<script>
export default {
  async mounted() {
    // Runs only in browser, after page loads
    const response = await fetch(`/api/products/${this.$route.params.id}`);
    this.product = await response.json();
  }
}
</script>
 
<!-- After: Server-side + client-side -->
<script>
export default {
  async asyncData({ params, $axios }) {
    // Runs on server during SSR, data included in HTML
    const product = await $axios.$get(`/api/products/${params.id}`);
    return { product };
  }
}
</script>
  1. nuxt.config.js for optimization:
export default {
  // Rendering mode
  mode: 'universal', // SSR enabled
 
  // Target
  target: 'server',
 
  // Performance optimizations
  render: {
    // Gzip compression
    compressor: require('compression')(),
 
    // Resource hints
    resourceHints: true,
 
    // HTTP2 push
    http2: {
      push: true,
      pushAssets: (req, res, publicPath, preloadFiles) =>
        preloadFiles
          .filter(f => f.asType === 'script' && f.file.includes('critical'))
          .map(f => `<${publicPath}${f.file}>; rel=preload; as=${f.asType}`)
    }
  },
 
  // Build optimizations
  build: {
    // Analyze bundle (run with: nuxt build --analyze)
    analyze: process.env.ANALYZE === 'true',
 
    // Extract CSS
    extractCSS: true,
 
    // Optimization
    optimization: {
      splitChunks: {
        chunks: 'all',
        automaticNameDelimiter: '.',
        name: undefined,
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            priority: 10,
            name: 'vendors'
          }
        }
      }
    },
 
    // Terser minification
    terser: {
      terserOptions: {
        compress: {
          drop_console: true // Remove console.logs in production
        }
      }
    }
  }
}

Result: Initial render time dropped from 8.3s to 4.2s (49% improvement)

Part 2: Code Splitting and Lazy Loading

Route-Level Code Splitting

Don’t load the entire app upfront. Split by route:

// Before: All routes loaded upfront
import Home from '@/views/Home.vue';
import Products from '@/views/Products.vue';
import Checkout from '@/views/Checkout.vue';
 
const routes = [
  { path: '/', component: Home },
  { path: '/products', component: Products },
  { path: '/checkout', component: Checkout }
];
 
// After: Lazy-load routes
const routes = [
  {
    path: '/',
    component: () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
  },
  {
    path: '/products',
    component: () => import(/* webpackChunkName: "products" */ '@/views/Products.vue')
  },
  {
    path: '/checkout',
    component: () => import(/* webpackChunkName: "checkout" */ '@/views/Checkout.vue')
  }
];

Result: Initial bundle size reduced from 2.3MB to 450KB

Component-Level Lazy Loading

Load heavy components only when needed:

<template>
  <div>
    <h1>Product Page</h1>
 
    <!-- Heavy chart component, lazy-load it -->
    <ClientOnly>
      <LazyProductAnalyticsChart v-if="showAnalytics" />
    </ClientOnly>
 
    <!-- Modal, only load when opened -->
    <LazyProductReviewModal v-if="showReviewModal" />
  </div>
</template>
 
<script>
export default {
  components: {
    // Lazy-load components
    LazyProductAnalyticsChart: () =>
      import('@/components/ProductAnalyticsChart.vue'),
    LazyProductReviewModal: () =>
      import('@/components/ProductReviewModal.vue')
  }
}
</script>

Nuxt.js auto-lazy-loading:

<!-- Nuxt auto-prefixes with Lazy to enable lazy loading -->
<template>
  <div>
    <LazyHeavyComponent />
  </div>
</template>
 
<!-- No import needed, Nuxt handles it -->

Image Lazy Loading

Use native lazy loading + progressive images:

<template>
  <div>
    <!-- Native lazy loading -->
    <img
      v-lazy="product.image"
      :alt="product.name"
      loading="lazy"
      width="800"
      height="600"
    >
 
    <!-- Or use nuxt-image for advanced optimization -->
    <nuxt-img
      :src="product.image"
      :alt="product.name"
      loading="lazy"
      format="webp"
      quality="80"
      sizes="sm:100vw md:50vw lg:400px"
    />
  </div>
</template>

Result: Page load with 50 product images went from 6.8s to 2.1s

Part 3: Webpack Optimization

Bundle Analysis

First, understand what’s in your bundle:

# Install webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
 
# Analyze build
nuxt build --analyze

Findings:

  • Moment.js: 278KB (just for date formatting!)
  • Lodash: 72KB (using only 3 functions)
  • Chart.js: 234KB (only on one page)

Optimizations Applied

1. Replace Moment.js with date-fns:

// Before
import moment from 'moment';
const formatted = moment(date).format('YYYY-MM-DD');
 
// After
import { format } from 'date-fns';
const formatted = format(date, 'yyyy-MM-dd');
 
// Savings: 250KB

2. Use Lodash selectively:

// Before (imports entire library)
import _ from 'lodash';
_.debounce(fn, 300);
 
// After (import specific functions)
import debounce from 'lodash/debounce';
debounce(fn, 300);
 
// Or use lodash-es for tree-shaking
import { debounce } from 'lodash-es';
 
// Savings: 65KB

3. Dynamic imports for heavy libraries:

// Load Chart.js only when needed
async showChart() {
  const Chart = await import('chart.js');
  this.initChart(Chart);
}

4. Webpack configuration tuning:

// nuxt.config.js
export default {
  build: {
    // Extend webpack config
    extend(config, { isDev, isClient }) {
      if (isClient) {
        // Ignore moment.js locales (saves ~160KB)
        config.plugins.push(
          new webpack.IgnorePlugin({
            resourceRegExp: /^\.\/locale$/,
            contextRegExp: /moment$/
          })
        );
 
        // Limit chunk size
        config.optimization.splitChunks.maxSize = 200000; // 200KB
      }
    },
 
    // Transpile specific packages
    transpile: ['some-es6-package'],
 
    // Optimize CSS
    extractCSS: {
      ignoreOrder: true
    },
 
    // PostCSS optimization
    postcss: {
      plugins: {
        'postcss-preset-env': {},
        'cssnano': { preset: 'default' }
      }
    }
  }
}

Result: Bundle size down from 2.3MB to 780KB (66% reduction)

Part 4: Caching Strategy

HTTP Caching Headers

// server middleware: server-middleware/headers.js
export default function (req, res, next) {
  const url = req.url;
 
  // Static assets: cache for 1 year
  if (url.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$/)) {
    res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
  }
 
  // HTML: revalidate frequently
  else if (url.match(/\.html$/)) {
    res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate');
  }
 
  // API responses: cache for 5 minutes
  else if (url.startsWith('/api/')) {
    res.setHeader('Cache-Control', 'public, max-age=300');
  }
 
  next();
}

Service Worker for Offline Support

// nuxt.config.js
export default {
  pwa: {
    workbox: {
      // Cache strategies
      runtimeCaching: [
        {
          urlPattern: '/api/products/.*',
          handler: 'networkFirst',
          options: {
            cacheName: 'api-products',
            expiration: {
              maxEntries: 50,
              maxAgeSeconds: 5 * 60 // 5 minutes
            }
          }
        },
        {
          urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
          handler: 'cacheFirst',
          options: {
            cacheName: 'images',
            expiration: {
              maxEntries: 100,
              maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
            }
          }
        }
      ]
    }
  }
}

Part 5: Asset Optimization

Image Optimization

// nuxt.config.js
export default {
  image: {
    // Use imgix, cloudinary, or custom provider
    provider: 'cloudinary',
 
    cloudinary: {
      baseURL: 'https://res.cloudinary.com/demo/image/upload/'
    },
 
    // Default image modifiers
    screens: {
      xs: 320,
      sm: 640,
      md: 768,
      lg: 1024,
      xl: 1280,
      xxl: 1536
    },
 
    // Enable modern formats
    formats: ['webp', 'avif'],
 
    // Default quality
    quality: 80
  }
}

Usage in components:

<nuxt-img
  src="/hero-image.jpg"
  alt="Hero"
  sizes="sm:100vw md:50vw lg:800px"
  format="webp"
  quality="80"
/>
 
<!-- Generates responsive images with srcset -->
<img
  src="hero-image.jpg?w=800&f=webp&q=80"
  srcset="
    hero-image.jpg?w=320&f=webp&q=80 320w,
    hero-image.jpg?w=640&f=webp&q=80 640w,
    hero-image.jpg?w=800&f=webp&q=80 800w
  "
  sizes="(max-width: 768px) 100vw, 50vw"
/>

Font Optimization

<!-- In layouts/default.vue -->
<head>
  <!-- Preconnect to font CDN -->
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
  <!-- Load fonts with display swap -->
  <link
    href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
    rel="stylesheet"
  >
</head>
 
<style>
/* Or self-host fonts for better performance */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
}
</style>

Final Results

Performance Metrics

MetricBeforeAfterImprovement
Initial Load (Desktop)8.3s1.6s80%
Initial Load (Mobile)12.1s2.8s77%
Time to Interactive12.1s2.4s80%
First Contentful Paint4.2s0.8s81%
Largest Contentful Paint9.1s1.9s79%
Bundle Size2.3MB780KB66%
PageSpeed Score23/10094/100309%

Business Impact

  • Bounce rate: 68% → 34% (50% reduction)
  • Conversion rate: 1.2% → 2.8% (133% increase)
  • SEO rankings: Page 3 → Page 1 for key terms
  • Mobile traffic: +127% increase
  • Revenue: +$180K/month attributed to improved performance

Key Takeaways

  1. SSR gives immediate wins: Especially for SEO and perceived performance

  2. Bundle size matters: Every KB slows mobile users. Analyze and optimize ruthlessly.

  3. Lazy loading everything: Routes, components, images, heavy libraries

  4. Webpack tuning is essential: Default configs are rarely optimal

  5. Measure continuously: Use Lighthouse, WebPageTest, and real user monitoring

  6. Performance is a feature: It directly impacts revenue

Tools Used

  • Nuxt.js: SSR framework
  • webpack-bundle-analyzer: Bundle size analysis
  • Lighthouse: Performance auditing
  • WebPageTest: Real-world performance testing
  • Cloudinary: Image optimization CDN
  • nuxt-image: Automatic image optimization

Optimizing web performance for your product? I’d love to discuss strategies and share experiences. Connect on LinkedIn.