How to optimise JavaScript to improve web performance and Core Web Vitals?
JavaScript optimisation follows three main strategies: bundle reduction (code splitting + tree shaking reduce size by 30–70%), load optimisation (async/defer for non-critical scripts) and main thread offloading (Web Workers for tasks >50ms). According to the HTTP Archive 2023, the median JavaScript per page is 509KB on mobile, being the main factor for main thread blocking and INP.
Key takeaways
- The median JavaScript per web page is 509KB on mobile (HTTP Archive 2023); sites with >1MB of JS have an average INP 3x higher than Google's threshold.
- Code splitting in Webpack/Vite divides the bundle into chunks loaded on demand; correctly implemented it can reduce initial JavaScript by 40–60%.
- Long Tasks (tasks >50ms on the main thread) are the primary cause of poor INP; Web Workers allow moving intensive processing off the main thread.
- Tree shaking eliminates unused code from the final bundle; requires ES modules (import/export), does not work with CommonJS (require). Can reduce bundles by 20–40%.
- Google's PRPL pattern (Push, Render, Pre-cache, Lazy-load) optimises progressive JavaScript delivery in Progressive Web Apps for slow networks.
The median web page ships 509KB of JavaScript on mobile. That figure from the HTTP Archive does not include what users actually need: it includes what developers added without removing later. Bundle growth tends to be one-directional, and the main thread pays the price.
The part most guides skip is that JavaScript is expensive in ways that go beyond file size. Every kilobyte must be downloaded, parsed, compiled and executed before it becomes functional. On a mid-range Android device, that chain can take three to five times longer than on a developer’s MacBook. What looks fast in DevTools can feel broken in the hands of your actual users.
This guide covers the optimisation techniques that have the most measurable impact on INP, LCP and total blocking time: bundle reduction, deferred loading and moving work off the main thread.
JavaScript optimisation fundamentals
Impact of JavaScript on Performance and Core Web Vitals
How it affects load speed
Before addressing solutions, it is important to understand exactly how code affects speed.
JS files are render-blocking resources by default. When the browser encounters a file, it stops processing the HTML until it downloads, parses and executes that code. Code execution also consumes CPU resources, especially on mobile devices with limited capabilities. Poorly optimised code can cause memory leaks, leading to degraded performance and even browser crashes.
The Core Web Vitals metrics most directly affected:
- Interaction to Next Paint (INP): Measures responsiveness to user interactions, replacing First Input Delay (FID) as the official metric in March 2024.
- Time to Interactive (TTI): Time until the page becomes fully interactive.
- Total Blocking Time (TBT): Total time the main thread is blocked.
| Metric | Unoptimised | With basic techniques | With advanced optimisation |
|---|---|---|---|
| Bundle size | 1.2MB | 780KB | 450KB |
| Load time | 4.2s | 2.8s | 1.5s |
| Total Blocking Time | 850ms | 340ms | 120ms |
| Memory usage | 82MB | 65MB | 48MB |
Loading Strategies: Code Splitting and Lazy Loading
Efficient loading with async and defer
The async and defer attributes control when the script executes relative to HTML parsing:
<!-- Blocks HTML parsing -->
<script src="script.js"></script>
<!-- Does not block HTML parsing, executes when ready -->
<script async src="script.js"></script>
<!-- Does not block HTML parsing, waits until HTML is completely parsed -->
<script defer src="script.js"></script>
| Feature | async | defer |
|---|---|---|
| Blocks HTML parsing | No | No |
| Execution order | Not guaranteed | Same document order |
| Execution timing | When download finishes | After HTML parsing |
| Ideal for | Independent files | DOM-dependent files |
Dynamic code loading
Load JS files only when needed:
function loadScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Load only when the user interacts
document.querySelector('.feature-button').addEventListener('click', async () => {
try {
await loadScript('/path/to/feature-script.js');
initFeature(); // Function defined in the loaded script
} catch (error) {
console.error('Error loading script:', error);
}
});
Implementing code splitting
Code splitting consists of dividing your code into smaller chunks that can be loaded on demand.
With Webpack
// Dynamic import
const button = document.querySelector('#load-component');
button.addEventListener('click', () => {
import(/* webpackChunkName: "component" */ './component.js')
.then(module => {
const component = module.default;
component.initialize();
})
.catch(error => {
console.error('Error loading component', error);
});
});
With React
import React, { lazy, Suspense } from 'react';
// Lazy import of the heavy component
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<div>
<h1>My Application</h1>
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
</div>
);
}
Bundle Reduction: Minification and Compression
Code minification
This process removes whitespace, comments and shortens variable names to reduce file size. The most widely used tools are Terser (current standard for modern JS), UglifyJS (for legacy code) and babel-minify (if you already use Babel in the pipeline).
Example with webpack:
// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
// ...
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // Removes console.log in production
},
},
})],
},
};
Compression further reduces transfer size. Gzip is widely supported with a good compression/CPU ratio; Brotli offers greater compression and is ideal for text and JavaScript.
Configuration on Apache server:
# Enable mod_deflate for Gzip compression
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript
</IfModule>
# For Brotli (requires mod_brotli)
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css application/javascript
</IfModule>
Tree shaking and bundle analysis
Tree shaking eliminates unused code from your bundles.
// utils.js
export function function1() { /* ... */ }
export function function2() { /* ... */ }
// main.js - Only function1 will be included in the final bundle
import { function1 } from './utils';
function1();
Bundle analysis
Tools for analysing the content of your bundles:
- webpack-bundle-analyzer
- source-map-explorer
# Install source-map-explorer
npm install --save-dev source-map-explorer
# Analyse a bundle
npx source-map-explorer dist/main.js
Execution Optimisation: Main Thread and Events
Techniques for freeing the main thread
The browser’s main thread is where both rendering and JavaScript execution occur. Keeping it free matters. Two techniques have the greatest impact:
Decompose long tasks
// Instead of this blocking code
function processLargeData(data) {
const results = [];
for (let i = 0; i < data.length; i++) {
results.push(processItem(data[i]));
}
return results;
}
// Use this non-blocking approach
function processLargeData(data, callback) {
const results = [];
let i = 0;
function processBatch() {
const start = performance.now();
// Process until reaching the time limit
while (i < data.length && performance.now() - start < 50) {
results.push(processItem(data[i]));
i++;
}
// If there is more data, schedule the next batch
if (i < data.length) {
setTimeout(processBatch, 0);
} else {
callback(results);
}
}
processBatch();
}
Web Workers for intensive tasks
// main.js
const worker = new Worker('worker.js');
worker.addEventListener('message', function(e) {
console.log('Result: ', e.data);
});
worker.postMessage({data: largeArray, operation: 'process'});
// worker.js
self.addEventListener('message', function(e) {
if (e.data.operation === 'process') {
const result = processData(e.data.data);
self.postMessage(result);
}
});
function processData(data) {
// Intensive operation that does not block the UI
return data.map(x => x * x).filter(x => x > 100);
}
Debounce, throttle and delegation
Poorly implemented event handlers can cause performance problems:
Debounce and throttle for frequent events:
// Debounce function - Executes the function after a delay from the last call
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Throttle function - Limits execution to once per time interval
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// Application to scroll or resize events
window.addEventListener('scroll', debounce(() => {
// Code that updates something based on scroll
}, 200));
window.addEventListener('resize', throttle(() => {
// Code that executes during resize
}, 300));
Event delegation:
// Instead of multiple listeners
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleClick);
});
// A single listener with delegation
document.querySelector('.container').addEventListener('click', (e) => {
if (e.target.matches('.item')) {
handleClick.call(e.target, e);
}
});
Modern Frameworks: React, Vue and the PRPL Pattern
Optimisations in React
Component memoisation
import React, { memo, useMemo, useCallback } from 'react';
// Component memoisation
const ExpensiveComponent = memo(function ExpensiveComponent(props) {
return <div>{/* Complex content */}</div>;
});
function App() {
// Memoisation of expensive-to-calculate values
const processedData = useMemo(() => {
return data.map(processItem);
}, [data]);
// Function memoisation
const handleClick = useCallback(() => {
// Handler logic
}, [dependencies]);
return (
<div>
<ExpensiveComponent data={processedData} onClick={handleClick} />
</div>
);
}
List virtualisation
import { FixedSizeList } from 'react-window';
function LargeList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].text}
</div>
);
return (
<FixedSizeList
height={400}
width="100%"
itemCount={items.length}
itemSize={35}
>
{Row}
</FixedSizeList>
);
}
Optimisations in Vue
Proper handling of v-for
<template>
<!-- Use key to optimise reactivity -->
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
<!-- For large lists, consider conditional rendering -->
<virtual-list :items="items" :height="400" :item-height="40">
<template v-slot:item="{ item }">
{{ item.name }}
</template>
</virtual-list>
</template>
Computed properties vs Methods
<script>
export default {
data() {
return {
items: [...],
search: ''
};
},
// Use computed for calculations that require caching
computed: {
filteredItems() {
return this.items.filter(item =>
item.name.toLowerCase().includes(this.search.toLowerCase())
);
}
},
// Use methods for operations that always need to execute
methods: {
handleClick() {
// Logic that always must execute
}
}
};
</script>
PRPL Pattern
The PRPL pattern is a strategy for structuring and serving web applications:
- Push (or Preload): Send/preload critical resources
- Render: Render the initial route as quickly as possible
- Pre-cache: Preload the remaining routes
- Lazy-load: Lazy load other routes and non-critical resources
Basic implementation:
<!-- Preload of critical resources -->
<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/js/app-core.js" as="script">
<!-- Precache with Service Worker -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
<!-- Prefetch of probable routes -->
<link rel="prefetch" href="/js/about-page.js">
// sw.js (Service Worker)
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('app-shell-v1').then((cache) => {
return cache.addAll([
'/',
'/css/critical.css',
'/js/app-core.js',
'/offline.html'
]);
})
);
});
Measurement and Practical Cases
Monitoring tools
To optimise effectively, you need to measure.
Diagnostic tools
- Lighthouse: Comprehensive performance assessment
- Chrome DevTools Performance panel: Detailed execution analysis
- WebPageTest: Performance tests under different conditions
Continuous production monitoring
- Core Web Vitals in Google Search Console: Real field data from CrUX
- Firebase Performance Monitoring: For web and mobile applications
- New Relic, Datadog: Commercial monitoring solutions
Measuring the impact of JavaScript on Core Web Vitals
// Manually calculate and report TBT
let tbtValue = 0;
let lastLongTaskEnd = 0;
// Create a PerformanceObserver for Long Tasks
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Calculate blocking time (time above 50ms)
const blockingTime = entry.duration - 50;
if (blockingTime > 0) {
tbtValue += blockingTime;
lastLongTaskEnd = entry.startTime + entry.duration;
}
}
// Send to analytics if threshold exceeded
if (tbtValue > 300) {
analytics.send('poor_tbt', tbtValue);
}
});
observer.observe({entryTypes: ['longtask']});
Case studies
Case 1: E-commerce
TTI of 12s on 3G mobile with a 1.2MB main JS bundle.
Optimisations applied: code splitting by routes and components, lazy loading of carousels and below-the-fold components, replacing jQuery with Vanilla JS, and strategic preloading of critical JS.
Results: TTI reduced to 4.5s (−62%), initial bundle to 320KB (−73%) and 28% improvement in mobile conversion rate.
Case 2: SPA Application
Average INP of 380ms with choppy animations during scroll.
Optimisations applied: migrating heavy tasks to Web Workers, virtualising long lists, route-based code splitting, and event handler optimisation with throttle/debounce.
Results: INP reduced to 85ms (−78%), within the good threshold (≤200ms); 95% reduction in Long Tasks during navigation; 40% improvement in engagement metrics.
Strategies by site type
Content sites
// Pattern for content sites
document.addEventListener('DOMContentLoaded', () => {
// Immediately load only essentials
loadEssentialFeatures();
// Defer non-critical functionality
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
loadNonEssentialFeatures();
});
} else {
setTimeout(loadNonEssentialFeatures, 1000);
}
// Load interaction functionality on demand
document.querySelector('.comments-button').addEventListener('click', () => {
import('./comments.js').then(module => {
module.initializeComments();
});
});
});
SPA applications
// Framework agnostic - Loading strategy for SPA
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
// API calls with Network First strategy (online) with cache fallback
registerRoute(
({url}) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 3
})
);
// Static resources with Cache First
registerRoute(
({request}) => request.destination === 'script' ||
request.destination === 'style',
new CacheFirst({
cacheName: 'static-resources'
})
);
// Predictive preloading based on user navigation
function preloadRoutes(currentRoute) {
const likelyNextRoutes = predictNextRoutes(currentRoute);
likelyNextRoutes.forEach(route => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = `/js/chunks/${route}.js`;
document.head.appendChild(link);
});
}
Future of JavaScript Optimisation
Code optimisation will remain a core part of web development. With the growing complexity of web applications and the evolution of standards, some trends to watch:
- Server Components run logic on the server and send HTML to the client, removing those components’ JS from the browser bundle entirely.
- ESBuild and SWC are ultra-fast compilers replacing Babel/Webpack in new projects, producing more efficient bundles by default.
- Edge Computing moves script execution to edge nodes, reducing network latency and offloading work from the client.
- Vite, Parcel and Next.js already incorporate automatic optimisations — tree shaking, code splitting, preloading — that previously required manual configuration.
The key to optimal efficiency is finding the right balance between functionality and lightness. Use the language strategically, loading only what the user needs when they need it, and optimising both the delivery and execution of code. Run PageSpeed Insights on your ten highest-traffic pages right now. If any return an LCP above 2.5 seconds or a CLS above 0.1, you have a specific problem to solve. Tell us what you found and we will tell you where to start.
Sources and references
-
HTTP Archive: Web Almanac 2023 - JavaScript (almanac.httparchive.org)
-
web.dev: Optimize long tasks (web.dev)
-
web.dev: Code splitting (web.dev)
-
MDN Web Docs: Web Workers API (developer.mozilla.org)
Share this article
If you found this content useful, share it with your colleagues.
Frequently Asked Questions
What is the difference between async and defer for loading JavaScript scripts?
async: the script is downloaded in parallel with the HTML and executes immediately when the download finishes, interrupting parsing. defer: the script is downloaded in parallel but only executes after the HTML has been completely parsed, respecting script order. For scripts that do not depend on the DOM or other scripts: async. For scripts that need the complete DOM: defer. Critical analytics or advertising scripts use async; main application JavaScript uses defer.
What is a bundle and how to reduce its size?
A bundle is the JavaScript file resulting from combining all modules of an application. To reduce it: 1) Minification with Terser (removes whitespace, renames variables). 2) Tree shaking to eliminate dead code. 3) Code splitting to divide into chunks. 4) Bundle analysis with webpack-bundle-analyzer to identify heavy dependencies. 5) Replace large libraries with lighter alternatives (moment.js → day.js saves ~67KB).
How does JavaScript affect INP (Interaction to Next Paint)?
INP measures the response latency of user interactions (clicks, keyboard, touch). JavaScript executing on the main thread blocks these responses. Main causes of high INP: Long Tasks (>50ms), heavy event handlers without debounce/throttle, unnecessary re-renders in React/Vue. Solutions: yield to main thread with scheduler.yield(), Web Workers for heavy calculations, and virtualisation of long lists.