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:
- Server-Side Rendering (Nuxt.js migration)
- Code Splitting (route and component-level)
- Lazy Loading (images, components, routes)
- Webpack Optimization (bundle size, caching)
- 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:
- 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>- 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 --analyzeFindings:
- 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: 250KB2. 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: 65KB3. 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
| Metric | Before | After | Improvement |
|---|---|---|---|
| Initial Load (Desktop) | 8.3s | 1.6s | 80% |
| Initial Load (Mobile) | 12.1s | 2.8s | 77% |
| Time to Interactive | 12.1s | 2.4s | 80% |
| First Contentful Paint | 4.2s | 0.8s | 81% |
| Largest Contentful Paint | 9.1s | 1.9s | 79% |
| Bundle Size | 2.3MB | 780KB | 66% |
| PageSpeed Score | 23/100 | 94/100 | 309% |
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
-
SSR gives immediate wins: Especially for SEO and perceived performance
-
Bundle size matters: Every KB slows mobile users. Analyze and optimize ruthlessly.
-
Lazy loading everything: Routes, components, images, heavy libraries
-
Webpack tuning is essential: Default configs are rarely optimal
-
Measure continuously: Use Lighthouse, WebPageTest, and real user monitoring
-
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
Recommended Reading
Optimizing web performance for your product? I’d love to discuss strategies and share experiences. Connect on LinkedIn.