JavaScript Performance Optimization for Better Core Web Vitals
Learn how to optimize JavaScript for better INP and page speed. Reduce bundle size, eliminate long tasks, and improve interactivity.
JavaScript is often the biggest bottleneck for page speed and Core Web Vitals. Heavy scripts block rendering, delay interactivity, and frustrate users. Here’s how to optimize JavaScript for better SEO performance.
How JavaScript Affects Core Web Vitals
Impact on INP
INP (Interaction to Next Paint) measures how quickly your page responds to interactions. JavaScript directly affects INP because:
- Long JavaScript tasks block the main thread
- Users can’t interact while JavaScript executes
- Heavy event handlers delay response to clicks
Impact on LCP
JavaScript can delay LCP by:
- Blocking rendering with synchronous scripts
- Client-side rendering delays content appearance
- Heavy JavaScript competing for bandwidth
Impact on CLS
JavaScript can cause CLS when:
- Scripts insert content after initial render
- Dynamic content changes layout
- Lazy loading without proper placeholders
Measuring JavaScript Performance
Chrome DevTools Performance Panel
- Open DevTools > Performance tab
- Click Record and interact with page
- Look for long tasks (marked in red)
- Identify slow JavaScript functions
Coverage Tab
Find unused JavaScript:
- Open DevTools > Coverage tab
- Click reload
- Red = unused code, Blue = used code
- Shows percentage of unused JavaScript
Lighthouse
Provides specific JavaScript recommendations:
- Reduce unused JavaScript
- Minimize main-thread work
- Reduce JavaScript execution time
Core Optimization Strategies
1. Reduce Bundle Size
Code splitting:
Split your bundle so users only download what they need:
// Instead of importing everything
import { heavyComponent } from './components';
// Import dynamically when needed
const heavyComponent = await import('./components/heavy');
Tree shaking:
Configure your bundler to remove unused exports:
// Bad - imports entire library
import _ from 'lodash';
_.debounce(fn, 100);
// Good - imports only what's needed
import debounce from 'lodash/debounce';
debounce(fn, 100);
Bundle analysis:
Use tools to visualize bundle contents:
- webpack-bundle-analyzer
- source-map-explorer
- bundlephobia (check package sizes before installing)
2. Defer Non-Critical JavaScript
Use async/defer:
<!-- Blocks rendering - avoid -->
<script src="app.js"></script>
<!-- Downloads in parallel, executes after HTML parsed -->
<script src="app.js" defer></script>
<!-- Downloads in parallel, executes as soon as available -->
<script src="app.js" async></script>
When to use each:
| Attribute | Best For |
|---|---|
| None | Inline scripts that must run immediately |
| async | Independent scripts (analytics, ads) |
| defer | Scripts that need DOM, order matters |
3. Break Up Long Tasks
Any JavaScript task over 50ms is a “long task” that blocks interactivity.
Before (one long task):
function processItems(items) {
items.forEach(item => {
// Heavy processing
heavyOperation(item);
});
}
After (broken into chunks):
async function processItems(items) {
const chunks = chunkArray(items, 10);
for (const chunk of chunks) {
chunk.forEach(item => heavyOperation(item));
// Yield to browser between chunks
await scheduler.yield();
}
}
// Fallback for browsers without scheduler.yield
async function yieldToMain() {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
4. Optimize Event Handlers
Debounce/throttle expensive handlers:
// Bad - fires on every scroll
window.addEventListener('scroll', expensiveFunction);
// Good - fires at most every 100ms
window.addEventListener('scroll', throttle(expensiveFunction, 100));
Use passive event listeners:
// Tells browser we won't call preventDefault()
// Allows smooth scrolling
window.addEventListener('scroll', handleScroll, { passive: true });
Move work off the main thread:
// Heavy computation in a Web Worker
const worker = new Worker('worker.js');
worker.postMessage({ data: largeArray });
worker.onmessage = (e) => {
// Update UI with result
displayResult(e.data);
};
5. Lazy Load JavaScript Features
Load on interaction:
// Load chart library only when user clicks "Show Chart"
button.addEventListener('click', async () => {
const { Chart } = await import('chart.js');
new Chart(canvas, config);
});
Load on visibility:
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
import('./comments.js').then(module => {
module.init();
});
observer.disconnect();
}
});
observer.observe(document.querySelector('#comments'));
Framework-Specific Optimizations
React
Use React.lazy for code splitting:
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
);
}
Memoize expensive components:
const ExpensiveList = React.memo(({ items }) => {
return items.map(item => <Item key={item.id} {...item} />);
});
Use startTransition for non-urgent updates:
import { startTransition } from 'react';
function handleChange(e) {
// Urgent: Update input
setInputValue(e.target.value);
// Non-urgent: Filter large list
startTransition(() => {
setFilteredList(filterLargeList(e.target.value));
});
}
Vue
Async components:
const HeavyComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
);
Use v-once for static content:
<span v-once>{{ staticData }}</span>
Next.js/Astro
Use Islands Architecture:
Only hydrate interactive components:
---
// Astro example
import StaticComponent from './Static.astro';
import InteractiveComponent from './Interactive.jsx';
---
<StaticComponent />
<InteractiveComponent client:visible />
Third-Party Script Optimization
Audit Third-Party Scripts
Common heavy third-party scripts:
- Chat widgets (often 200KB+)
- Analytics (Google Analytics, etc.)
- Advertising scripts
- Social sharing buttons
- Video embeds
Delay Third-Party Loading
// Load chat widget after 5 seconds or user interaction
let loaded = false;
function loadChatWidget() {
if (loaded) return;
loaded = true;
const script = document.createElement('script');
script.src = 'https://chat.example.com/widget.js';
document.body.appendChild(script);
}
setTimeout(loadChatWidget, 5000);
document.addEventListener('scroll', loadChatWidget, { once: true });
Use Facades
Replace heavy embeds with lightweight placeholders:
<!-- Instead of loading entire YouTube player -->
<div class="youtube-facade" data-video="abc123">
<img src="thumbnail.jpg" alt="Video">
<button>Play</button>
</div>
<script>
document.querySelector('.youtube-facade').addEventListener('click', (e) => {
const videoId = e.currentTarget.dataset.video;
e.currentTarget.innerHTML = `
<iframe src="https://youtube.com/embed/${videoId}?autoplay=1"
frameborder="0" allowfullscreen></iframe>
`;
});
</script>
Monitoring JavaScript Performance
Real User Monitoring (RUM)
Track actual user JavaScript performance:
// Using web-vitals library
import { onINP, onTBT } from 'web-vitals';
onINP(metric => {
// Send to analytics
analytics.track('INP', metric.value);
});
Continuous Monitoring
- Set up alerts for INP regressions
- Track bundle size in CI/CD
- Monitor Core Web Vitals in Search Console
JavaScript Optimization Checklist
Bundle Size
- Code split large bundles
- Tree shake unused code
- Analyze bundle contents
- Remove unused dependencies
Loading
- Defer non-critical scripts
- Async load independent scripts
- Lazy load features on interaction
- Preload critical scripts
Execution
- No JavaScript tasks over 50ms
- Event handlers debounced/throttled
- Passive event listeners used
- Heavy work in Web Workers
Third-Party
- Third-party scripts audited
- Non-essential scripts deferred
- Facades for heavy embeds
- Tag managers optimized
Monitoring
- RUM tracking INP
- Bundle size in CI/CD
- Regular Lighthouse audits
- Performance budgets set
Related Resources:
Tags:
Share this article:
Ready to improve your SEO?
Get a free SEO audit and see exactly what needs fixing on your site
Start Free Audit