In today’s competitive digital landscape, a slow‑loading website can cost you visitors, conversions, and search rankings. While modern browsers are getting faster, the amount of JavaScript, CSS, and media we ship has exploded. The secret sauce to reclaiming speed lies in two complementary strategies: lazy loading and code splitting. This guide walks you through the entire process—from auditing your bundle to deploying a finely tuned, performance‑first build—using real commands, configuration snippets, and best‑practice advice.
What You’ll Need
- A modern JavaScript framework (React, Vue, or Angular) or a vanilla‑JS project
- Node.js ≥ 16 and npm or yarn
- Webpack 5, Rollup 3, or Vite 2+ as your bundler
- Chrome DevTools and Lighthouse installed
- Basic knowledge of ES modules and async/await
Step 1: Audit Your Current Bundle
Before you can optimise, you need a baseline. Run a bundle analysis to visualise what’s eating your bytes. For Webpack, install webpack-bundle-analyzer:
npm install --save-dev webpack-bundle-analyzer Then add a script to package.json:
"analyze": "webpack --mode production --profile --json > stats.json && webpack-bundle-analyzer stats.json" Execute npm run analyze and study the treemap. Identify large third‑party libraries, duplicated code, and assets that could be deferred. This snapshot will help you measure the impact of every optimisation you apply.
Step 2: Implement Lazy Loading for Images and Media
Native lazy loading is now supported by all major browsers via the loading="lazy" attribute. Replace static <img> tags with:
<img src="hero.jpg" alt="Hero" loading="lazy" width="1200" height="800"> For background images or older browsers, use the IntersectionObserver API. Create a utility file lazyLoad.js:
export function lazyLoadImages() {
const images = document.querySelectorAll('img[data-src]');
const options = {
rootMargin: '0px 0px 200px 0px',
threshold: 0.1
};
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
obs.unobserve(img);
}
});
}, options);
images.forEach(img => observer.observe(img));
}
In your entry point, call lazyLoadImages() after the DOM is ready. Remember to add data-src="image.jpg" instead of src on each image you want to defer.
Step 3: Apply Dynamic Imports for Code Splitting
Static imports bundle everything up front, even code that runs only on a specific route or interaction. Replace them with dynamic import() calls. In a React router example:
const Home = React.lazy(() => import('./pages/Home'));
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/dashboard" component={Dashboard} />
</Switch>
</Suspense>
);
}
Webpack automatically creates separate chunks for each dynamic import. You can fine‑tune the chunk name with a magic comment:
import(/* webpackChunkName: "dashboard" */ './pages/Dashboard'); For Vue, use defineAsyncComponent; for Angular, leverage the router’s loadChildren property.
Step 4: Configure Your Bundler for Optimal Splitting
Webpack’s splitChunks configuration gives you granular control over how modules are shared across chunks. Add this to webpack.config.js under optimization:
optimization: {
splitChunks: {
chunks: 'all',
minSize: 30_000,
maxSize: 250_000,
cacheGroups: {
vendor: {
test: /[\/]node_modules[\/]/,
name: 'vendors',
chunks: 'all',
priority: -10,
},
common: {
name: 'common',
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
runtimeChunk: { name: entrypoint => `runtime-${entrypoint.name}` },
},
If you use Rollup, the output.manualChunks option works similarly:
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
return 'vendor';
}
}
}
These settings ensure that third‑party libraries are cached separately, and that the runtime code is small enough to be inlined.
Step 5: Test Performance with Lighthouse and Web Vitals
Run Lighthouse in Chrome DevTools (or via npm run lh using lighthouse CLI). Focus on the following metrics:
- First Contentful Paint (FCP) – should drop below 1.5 s on a 3G connection.
- Largest Contentful Paint (LCP) – aim for < 2.5 s.
- Total Blocking Time (TBT) – keep under 300 ms.
- Cumulative Layout Shift (CLS) – stay below 0.1.
Record the baseline numbers, then re‑run after each optimisation step. The delta will confirm whether your lazy loading or code splitting actually improves perceived speed.
Step 6: Fine‑Tune IntersectionObserver Thresholds
Too aggressive a rootMargin can cause images to load earlier than necessary, negating the benefit of lazy loading. Conversely, a margin that’s too small may cause visible placeholders when a user scrolls quickly. Experiment with values like rootMargin: '0px 0px 300px 0px' for image‑heavy pages, and adjust the threshold array to [0, 0.25, 0.5] for progressive loading.
Also, debounce the observer callback if you have many images on the page:
let timeout;
const observer = new IntersectionObserver((entries) => {
clearTimeout(timeout);
timeout = setTimeout(() => handleEntries(entries), 100);
}, options);
This reduces layout thrashing and keeps the main thread free for user interactions.
Step 7: Deploy, Monitor, and Iterate
Once your build passes Lighthouse with a score above 90, ship it to production. Use a CDN that supports Cache‑Control headers for your split chunks:
Cache-Control: public, max-age=31536000, immutable Set a short max‑age for HTML (e.g., 60 seconds) so you can roll out hotfixes without stale content. Finally, integrate Real‑User Monitoring (RUM) tools like Google Analytics’ Site Speed or SpeedCurve to collect field data. If you notice a spike in LCP after a new feature, revisit the relevant chunk and consider further splitting or pre‑loading.
Common Mistakes to Avoid
1 Forgetting to add width and height attributes. Without them the browser must re‑calculate layout, inflating CLS.
2 Over‑splitting. Creating dozens of tiny chunks increases HTTP request overhead and can hurt performance on high‑latency networks.
3 Hard‑coding import() inside loops. This triggers a new network request on each iteration. Extract the import outside the loop and cache the promise.
4 Neglecting fallback UI. Users on browsers without IntersectionObserver will see blank spaces unless you polyfill or use the native loading="lazy" attribute.
5 Missing preload for critical chunks. If a chunk is needed immediately (e.g., the first route in a SPA), add <link rel="preload" as="script" href="/static/js/home.chunk.js"> to avoid a flash‑of‑blank.
Tips and Tricks
• Use webpack --profile --json > stats.json together with webpack-bundle-analyzer to spot duplicate modules that can be deduped with resolve.alias.
• Leverage prefetch for routes the user is likely to visit next. In React Router you can add rel="prefetch" links in the useEffect of the current page.
• Combine lazy loading with responsive images (srcset and sizes) to deliver the right pixel density.
• If you serve a PWA, add serviceWorker.register() and cache the split chunks using a “stale‑while‑revalidate” strategy.
• Monitor bundle size with npm run size (using size-limit) to enforce a CI gate.
Frequently Asked Questions
Does lazy loading affect SEO?
Googlebot now executes JavaScript and respects native loading="lazy". However, always provide noscript fallbacks for critical images to guarantee crawlability.
Can I lazy load CSS?
Yes. Split non‑critical CSS into separate files and load them with rel="preload" as="style" onload="this.rel='stylesheet'". The media attribute (e.g., media="print") also defers loading until needed.
How do I know which chunk to pre‑load?
Analyse navigation patterns with analytics. If 70 % of users go from the homepage to the product page, pre‑load the product bundle on the homepage using <link rel="prefetch" href="/static/js/product.chunk.js">.
Conclusion
Lazy loading and code splitting are not just buzzwords—they are concrete, measurable techniques that shrink payloads, improve Core Web Vitals, and keep users engaged. By auditing your bundle, applying native and IntersectionObserver lazy loading, leveraging dynamic imports, fine‑tuning your bundler, and rigorously testing with Lighthouse, you create a resilient performance pipeline that scales as your app grows. Remember to avoid common pitfalls, monitor real‑world metrics, and iterate continuously. Master these strategies and you’ll deliver faster, lighter, and more SEO‑friendly web experiences that delight both users and search engines.
Photo by Stephen Phillips – Hostreviews.co.uk on Unsplash





