Frontend Performance Optimization: From 8s to 2s Load Time
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.jsvendor.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:
| Metric | Before | After | Improvement |
|---|---|---|---|
| First Paint | 3.2s | 0.9s | 72% |
| Time to Interactive | 8.1s | 2.1s | 74% |
| Page Size | 2.4MB | 680KB | 72% |
| Requests | 87 | 23 | 74% |
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
- Measure first - Don’t optimize blindly
- Low-hanging fruit - Image optimization gives biggest wins
- Code splitting - Don’t ship code users don’t need
- Caching is king - Proper cache headers are crucial
- Monitor continuously - Performance degrades over time
What’s Next
Future optimizations:
- Service Worker - Offline support and faster repeat visits
- HTTP/2 - Multiplexing and server push
- WebP images - Better compression than JPEG
- 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.