Our web application was getting slower. Users complained about long load times, especially on mobile. I ran Lighthouse and saw the problem: 8 seconds to interactive on 3G.

I spent two weeks optimizing. Here’s how I got it down to 2 seconds.

Table of Contents

The Baseline: Measuring Performance

First, I measured everything. You can’t optimize what you don’t measure.

Tools I used:

  • Chrome DevTools - Network and Performance tabs
  • WebPageTest - Real-world testing on different connections
  • Google PageSpeed Insights - Automated recommendations

Initial metrics:

  • First Paint: 3.2s
  • Time to Interactive: 8.1s
  • Total Page Size: 2.4MB
  • Requests: 87

These numbers are terrible. Let’s fix them.

Problem 1: Massive JavaScript Bundle

Our main JavaScript bundle was 1.2MB (uncompressed). That’s way too big.

Analysis with webpack-bundle-analyzer:

npm install --save-dev webpack-bundle-analyzer

# webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
};

The analyzer showed:

  • Moment.js: 230KB (we only use it in one place!)
  • Lodash: 70KB (importing entire library)
  • Unused vendor code: 300KB+

Solution 1: Code Splitting

I split the bundle into chunks:

// webpack.config.js
module.exports = {
  entry: {
    app: './src/main.js',
    vendor: ['vue', 'vue-router', 'axios']
  },
  output: {
    filename: '[name].[chunkhash].js',
    path: path.resolve(__dirname, 'dist')
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor'
    })
  ]
};

This separates vendor code (changes rarely) from app code (changes often). Better caching.

Solution 2: Lazy Loading Routes

Not every user visits every page. Load routes on demand:

// Before: eager loading
import Dashboard from './components/Dashboard.vue'
import Profile from './components/Profile.vue'
import Settings from './components/Settings.vue'

const routes = [
  { path: '/dashboard', component: Dashboard },
  { path: '/profile', component: Profile },
  { path: '/settings', component: Settings }
]

// After: lazy loading
const routes = [
  { 
    path: '/dashboard', 
    component: () => import('./components/Dashboard.vue')
  },
  { 
    path: '/profile', 
    component: () => import('./components/Profile.vue')
  },
  { 
    path: '/settings', 
    component: () => import('./components/Settings.vue')
  }
]

Now each route is a separate chunk, loaded only when needed.

Solution 3: Tree Shaking

Import only what you need:

// Before: imports entire library
import _ from 'lodash'
_.debounce(fn, 300)

// After: import specific function
import debounce from 'lodash/debounce'
debounce(fn, 300)

// Or use lodash-es for better tree shaking
import { debounce } from 'lodash-es'

For Moment.js, I switched to date-fns (much smaller):

// Before: Moment.js (230KB)
import moment from 'moment'
moment(date).format('YYYY-MM-DD')

// After: date-fns (10KB for what we use)
import format from 'date-fns/format'
format(date, 'YYYY-MM-DD')

Problem 2: Unoptimized Images

Images were 800KB total, and most were larger than displayed size.

Solution 4: Image Optimization

I used imagemin to compress images:

npm install --save-dev imagemin imagemin-mozjpeg imagemin-pngquant

# optimize.js
const imagemin = require('imagemin');
const imageminMozjpeg = require('imagemin-mozjpeg');
const imageminPngquant = require('imagemin-pngquant');

imagemin(['src/images/*.{jpg,png}'], 'dist/images', {
  plugins: [
    imageminMozjpeg({ quality: 80 }),
    imageminPngquant({ quality: [0.6, 0.8] })
  ]
}).then(() => console.log('Images optimized'));

Results:

  • Before: 800KB
  • After: 320KB (60% reduction)

Solution 5: Responsive Images

Serve different sizes for different screens:

<!-- Before: one size for all -->
<img src="hero.jpg" alt="Hero">

<!-- After: responsive -->
<img 
  srcset="hero-320.jpg 320w,
          hero-640.jpg 640w,
          hero-1024.jpg 1024w"
  sizes="(max-width: 320px) 280px,
         (max-width: 640px) 600px,
         1024px"
  src="hero-1024.jpg"
  alt="Hero">

Mobile users now download smaller images.

Solution 6: Lazy Load Images

Images below the fold don’t need to load immediately:

// lazy-load.js
document.addEventListener('DOMContentLoaded', function() {
  const lazyImages = document.querySelectorAll('img[data-src]');
  
  const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.removeAttribute('data-src');
        observer.unobserve(img);
      }
    });
  });
  
  lazyImages.forEach(img => imageObserver.observe(img));
});

HTML:

<img data-src="image.jpg" alt="Lazy loaded">

This uses the Intersection Observer API (new in 2016). For older browsers, I have a polyfill.

Problem 3: Render-Blocking CSS

CSS was blocking the first paint.

Solution 7: Critical CSS

Extract above-the-fold CSS and inline it:

npm install --save-dev critical

# critical.js
const critical = require('critical');

critical.generate({
  inline: true,
  base: 'dist/',
  src: 'index.html',
  dest: 'index.html',
  width: 1300,
  height: 900
});

This inlines critical CSS in <head> and loads the rest asynchronously:

<head>
  <style>
    /* Critical CSS inlined here */
  </style>
  <link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="styles.css"></noscript>
</head>

Solution 8: Remove Unused CSS

I used UnCSS to remove unused styles:

npm install --save-dev uncss

# uncss.js
const uncss = require('uncss');

uncss(['dist/index.html'], {
  stylesheets: ['dist/styles.css']
}, (error, output) => {
  fs.writeFileSync('dist/styles.min.css', output);
});

Results:

  • Before: 180KB CSS
  • After: 45KB CSS (75% reduction)

Problem 4: No Caching Strategy

Static assets had no cache headers.

Solution 9: Cache Busting with Hashes

Add content hashes to filenames:

// webpack.config.js
module.exports = {
  output: {
    filename: '[name].[chunkhash].js',
    chunkFilename: '[name].[chunkhash].js'
  },
  plugins: [
    new ExtractTextPlugin('[name].[contenthash].css')
  ]
};

Now files have unique names when content changes:

  • app.a3f2b1c.js
  • vendor.d4e5f6g.js

Set aggressive caching in nginx:

location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

Solution 10: Enable Compression

Enable gzip in nginx:

gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript 
           application/x-javascript application/xml+rss 
           application/javascript application/json;

Results:

  • JavaScript: 400KB → 120KB (70% reduction)
  • CSS: 45KB → 12KB (73% reduction)

Solution 11: Preload Critical Resources

Tell the browser what to load first:

<head>
  <link rel="preload" href="app.js" as="script">
  <link rel="preload" href="vendor.js" as="script">
  <link rel="preload" href="fonts/main.woff2" as="font" type="font/woff2" crossorigin>
</head>

This starts downloading critical resources earlier.

Solution 12: Use a CDN

I moved static assets to CloudFront:

// webpack.config.js
module.exports = {
  output: {
    publicPath: 'https://d1234.cloudfront.net/'
  }
};

Benefits:

  • Faster downloads - Served from edge locations
  • Parallel downloads - Different domain, more connections
  • Reduced server load - Assets served by CDN

Problem 5: Slow API Calls

The app made 15+ API calls on initial load.

Solution 13: Batch API Requests

Combine multiple requests:

// Before: 3 separate requests
const user = await api.get('/user')
const posts = await api.get('/posts')
const comments = await api.get('/comments')

// After: 1 batched request
const { user, posts, comments } = await api.get('/batch', {
  params: { 
    resources: ['user', 'posts', 'comments'] 
  }
})

Backend endpoint:

@app.route('/batch')
def batch():
    resources = request.args.getlist('resources')
    result = {}
    
    if 'user' in resources:
        result['user'] = get_user()
    if 'posts' in resources:
        result['posts'] = get_posts()
    if 'comments' in resources:
        result['comments'] = get_comments()
    
    return jsonify(result)

Reduced from 15 requests to 3.

Solution 14: Cache API Responses

Use localStorage to cache responses:

async function fetchWithCache(url, ttl = 300000) {
  const cached = localStorage.getItem(url)
  
  if (cached) {
    const { data, timestamp } = JSON.parse(cached)
    if (Date.now() - timestamp < ttl) {
      return data
    }
  }
  
  const response = await fetch(url)
  const data = await response.json()
  
  localStorage.setItem(url, JSON.stringify({
    data,
    timestamp: Date.now()
  }))
  
  return data
}

This reduces redundant API calls.

Results

After all optimizations:

MetricBeforeAfterImprovement
First Paint3.2s0.9s72%
Time to Interactive8.1s2.1s74%
Page Size2.4MB680KB72%
Requests872374%

PageSpeed score went from 42 to 89.

Monitoring

I set up performance monitoring with Google Analytics:

// Track page load time
window.addEventListener('load', () => {
  const loadTime = performance.timing.loadEventEnd - performance.timing.navigationStart
  
  ga('send', 'timing', 'Page Load', 'load', loadTime)
})

// Track Time to Interactive
if ('PerformanceObserver' in window) {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.name === 'first-contentful-paint') {
        ga('send', 'timing', 'Paint', 'FCP', entry.startTime)
      }
    }
  })
  observer.observe({ entryTypes: ['paint'] })
}

Now I can track performance over time.

Lessons Learned

  1. Measure first - Don’t optimize blindly
  2. Low-hanging fruit - Image optimization gives biggest wins
  3. Code splitting - Don’t ship code users don’t need
  4. Caching is king - Proper cache headers are crucial
  5. Monitor continuously - Performance degrades over time

What’s Next

Future optimizations:

  1. Service Worker - Offline support and faster repeat visits
  2. HTTP/2 - Multiplexing and server push
  3. WebP images - Better compression than JPEG
  4. Prefetch - Predict and preload next pages

Conclusion

Performance optimization is an ongoing process. These changes took two weeks but improved user experience dramatically.

Key takeaways:

  • Measure everything
  • Optimize images first (biggest impact)
  • Split code and lazy load
  • Enable compression and caching
  • Monitor performance continuously

A fast website is a better website. Users notice, and they appreciate it.

Our bounce rate dropped 15% after these optimizations. Speed matters.