JavaScript Performance: 8 Fixes That Actually Matter (2026)
Not all performance optimizations are worth your time. These 8 are.
How to Measure (Before You Optimize!)
// Rule #1: Never optimize without measuring first
// Browser DevTools:
// → Performance tab: Record, analyze flame chart
// → Network tab: Waterfall view of requests
// → Memory tab: Heap snapshots, allocation timeline
// → Coverage tab: See unused JS/CSS (huge quick wins!)
// Node.js:
console.time('operation');
doSomething();
console.timeEnd('operation'); // operation: 123.456ms
// More precise:
const start = process.hrtime.bigint();
doSomething();
const end = process.hrtime.bigint();
console.log(`Duration: ${Number(end - start) / 1e6}ms`);
// Professional benchmarking
const { performance, PerformanceObserver } = require('perf_hooks');
const obs = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach((entry) => {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
});
});
obs.observe({ entryTypes: ['measure'] });
performance.mark('startQuery');
runDatabaseQuery();
performance.mark('endQuery');
performance.measure('query', 'startQuery', 'endQuery');
Fix #1: Debounce & Throttle Expensive Operations
// Problem: Search input fires on every keystroke
input.addEventListener('input', (e) => {
search(e.target.value); // Fires 50+ times per second!
});
// Solution A: Debounce — wait until user stops typing
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
const smartSearch = debounce(search, 300); // Wait 300ms after last keystroke
input.addEventListener('input', (e) => smartSearch(e.target.value));
// Solution B: Throttle — run at most once per interval
function throttle(fn, interval) {
let lastCall = 0;
return function (...args) {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
fn.apply(this, args);
}
};
}
const throttledScroll = throttle(logScrollPosition, 100); // Max once per 100ms
window.addEventListener('scroll', throttledScroll);
// When to use which?
// Debounce: search, resize calculations, form validation
// Throttle: scroll handlers, button clicks, mousemove tracking
// Modern: requestAnimationFrame for visual updates
function rafThrottle(fn) {
let rafId = null;
return function (...args) {
if (rafId === null) {
rafId = requestAnimationFrame(() => {
fn.apply(this, args);
rafId = null;
});
}
};
}
Fix #2: Lazy Load Everything Possible
<!-- Images: native lazy loading -->
<img src="photo.jpg" loading="lazy" alt="..." width="800" height="600" />
<!-- Always include width/height to prevent layout shift! -->
<!-- Dynamic imports (code splitting) -->
<script type="module">
// Don't load heavy libraries until needed
document.getElementById('chart-btn').addEventListener('click', async () => {
const { Chart } = await import('./chart-lib.js');
new Chart(ctx, config);
});
// Route-based code splitting (React/Vue do this automatically)
const Dashboard = () => import('./Dashboard.vue');
</script>
<!-- Intersection Observer for "load when visible" -->
<div class="lazy-section" data-src="/api/section-data"></div>
<script>
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadSection(entry.target);
observer.unobserve(entry.target); // Only load once!
}
});
},
{ rootMargin: '200px' } // Start loading 200px before visible
);
document.querySelectorAll('.lazy-section').forEach((el) => observer.observe(el));
</script>
// Virtual scrolling (for long lists — render only visible items)
// Libraries: react-window, tanstack-virtual, @tanstack/react-virtual
// Without it: 10,000 items = 10,000 DOM nodes = slow
// With it: 10,000 items = ~20 DOM nodes (visible viewport) = fast
import { useVirtualizer } from '@tanstack/react-virtual';
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
// Only renders rows that are currently visible (+/- 5 buffer)
Fix #3: Optimize Data Fetching
// ❌ Bad: N+1 problem
async function fetchUsersWithPosts() {
const users = await fetch('/api/users').then(r => r.json());
for (const user of users) {
user.posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json());
// If 100 users = 101 HTTP requests!
}
return users;
}
// ✅ Good: Batch in single request
async function fetchUsersWithPosts() {
const [users, posts] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts?includeUsers=true').then(r => r.json()),
]);
// Map posts to users client-side
return users.map(u => ({ ...u, posts: posts.filter(p => p.userId === u.id) }));
}
// Only 2 HTTP requests regardless of user count!
// ✅ Better: Backend returns nested data in one call
const data = await fetch('/api/users?_embed=posts').then(r => r.json());
// Single request!
// Deduplicate concurrent requests (same URL requested twice simultaneously)
const pendingRequests = new Map();
function deduplicatedFetch(url, options) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url); // Return same promise!
}
const promise = fetch(url, options)
.finally(() => pendingRequests.delete(url));
pendingRequests.set(url, promise);
return promise;
}
// Cache GET responses (simple TTL cache)
const cache = new Map();
async function cachedFetch(url, ttlMs = 5000) {
const cached = cache.get(url);
if (cached && Date.now() - cached.timestamp < ttlMs) {
return cached.data; // Return from cache!
}
const data = await fetch(url).then(r => r.json());
cache.set(url, { data, timestamp: Date.now() });
return data;
}
// Stale-while-revalidate (show cached, update in background)
async function swrFetch(url) {
const cached = cache.get(url);
if (cached) {
// Return stale data immediately
fetch(url).then(r => r.json()).then(data => {
cache.set(url, { data, timestamp: Date.now() }); // Update cache
});
return cached.data;
}
// No cache: fetch and wait
const data = await fetch(url).then(r => r.json());
cache.set(url, { data, timestamp: Date.now() });
return data;
}
Fix #4: Reduce Re-renders
// React example (same principles apply elsewhere)
// ❌ Bad: New object/array on every render
function Parent() {
return (
<Child
onClick={() => handleClick(id)} // New function every render!
style={{ color: 'red' }} // New object every render!
data={items.map(i => ({...i}))} // New array every render!
/>
);
}
// ✅ Good: Stable references
const handleClickMemoized = useCallback(
() => handleClick(id),
[id] // Only recreate when id changes
);
const styleMemoized = useMemo(
() => ({ color: 'red' }),
[] // Never changes
);
const dataMemoized = useMemo(
() => items.map(i => ({...i})),
[items] // Only recompute when items change
);
return <Child onClick={handleClickMemoized} style={styleMemoized} data={dataMemoized} />;
// General principle (framework-agnostic):
// Props/inputs should be referentially stable between renders.
// If a prop is the same value, use the SAME reference (object/function).
// This enables efficient comparison (=== instead of deep equality).
Fix #5: Efficient DOM Operations
// ❌ Bad: Multiple DOM writes (reflow triggers!)
list.forEach(item => {
const el = document.createElement('div');
el.textContent = item.name;
el.className = 'item';
container.appendChild(el); // Reflow each time!
});
// ✅ Good: Document fragment (single reflow)
const fragment = document.createDocumentFragment();
list.forEach(item => {
const el = document.createElement('div');
el.textContent = item.name;
el.className = 'item';
fragment.appendChild(el); // No reflow!
});
container.appendChild(fragment); // Single reflow!
// ✅ Better: innerHTML (for static content)
container.innerHTML = list
.map(item => `<div class="item">${escapeHtml(item.name)}</div>`)
.join('');
// ✅ Best: CSS containment + batch reads/writes
// Read all layout info first, then write all at once
function updateLayout(elements) {
// Phase 1: READ all (forces reflow once)
const heights = elements.map(el => el.offsetHeight);
// Phase 2: WRITE all (triggers one reflow)
elements.forEach((el, i) => {
el.style.height = `${heights[i]}px`;
});
}
// Use CSS containment for isolation
.isolated-component {
contain: layout style paint;
/* Browser knows this element's changes won't affect outside */
/* Optimizes rendering significantly */
}
Fix #6: Web Workers for Heavy Computation
// Main thread stays responsive while worker does heavy lifting
// worker.js
self.onmessage = function(e) {
const { data, operation } = e.data;
switch (operation) {
case 'sort':
const sorted = [...data].sort((a, b) => b.value - a.value);
self.postMessage({ result: sorted });
break;
case 'filter':
const filtered = data.filter(item => item.active);
self.postMessage({ result: filtered });
break;
case 'process':
// Heavy computation that would freeze UI
const processed = data.map(heavyTransform);
self.postMessage({ result: processed });
break;
}
};
// main.js
const worker = new Worker('worker.js');
worker.onmessage = function(e) {
console.log('Worker finished:', e.data.result);
updateUI(e.data.result);
};
worker.onerror = function(err) {
console.error('Worker error:', err.message);
};
// Send work to worker
worker.postMessage({
data: largeDataset,
operation: 'process'
});
// UI remains responsive while worker processes!
// For ad-hoc tasks (no separate file needed):
const code = `
self.onmessage = function(e) {
const result = e.data.reduce((sum, n) => sum + n * n, 0);
self.postMessage(result);
};
`;
const blob = new Blob([code], { type: 'application/javascript' });
const adhocWorker = new Worker(URL.createObjectURL(blob));
adhocWorker.onmessage = (e) => console.log('Sum of squares:', e.data);
adhocWorker.postMessage([1, 2, 3, 4, 5]); // 55
Fix #7: Image Optimization
<!-- Responsive images -->
<picture>
<!-- Mobile: small image -->
<source srcset="hero-small.webp" media="(max-width: 600px)" type="image/webp" />
<!-- Tablet: medium image -->
<source srcset="hero-medium.webp" media="(max-width: 1200px)" type="image/webp" />
<!-- Desktop: large image with high-DPI variant -->
<source
srcset="hero-large.webp 1x, hero-large-2x.webp 2x"
type="image/webp"
/>
<!-- Fallback -->
<img src="hero-large.jpg" alt="Hero image" width="1200" height="600" decoding="async" />
</picture>
<!-- Critical images: preload -->
<link rel="preload" as="image" href="hero.webp" type="image/webp" />
<!-- Background images via CSS (not img tags) -->
.hero {
background-image: url(/images/hero.webp);
background-size: cover;
/* Or with media query for different sizes */
}
<!-- Blur-up technique (instant preview + full resolution) -->
<img
style="background-size: cover; background-image: url(hero-tiny-blur.jpg);"
src="hero-full.jpg"
loading="lazy"
decoding="async"
/>
<!-- Shows blurry tiny image instantly, then full-res loads -->
// Programmatic optimization
// Convert images to modern formats (WebP/AVIF)
// Tools: sharp (Node.js), imagemin CLI, Cloudflare Image Resizing
const sharp = require('sharp');
await sharp('input.png')
.resize(800, null) // Width 800, auto height
.webp({ quality: 80 }) // WebP format (25-35% smaller than JPEG)
.toFile('output.webp'); // Output file
// Generate multiple sizes for responsive images
const sizes = [
{ name: 'small', width: 400 },
{ name: 'medium', width: 800 },
{ name: 'large', width: 1200 },
];
for (const size of sizes) {
await sharp('input.jpg')
.resize(size.width)
.jpeg({ quality: 75 })
.toFile(`output-${size.name}.jpg`);
}
Fix #8: Bundle Size Reduction
# Analyze what's in your bundle
npx webpack-bundle-analyzer dist/stats.json
# Visual tree map of everything in your bundle!
# Find large dependencies
npx cost-of-modules --path ./package.json
# Lists every dependency by size (gzipped)
# Tree shaking check
npx depcheck
# Finds unused dependencies you can remove
// Import only what you need
// ❌ Bad: Import entire library
import _ from 'lodash'; // ~70KB gzipped!
import moment from 'moment'; // ~300KB gzipped!
// ✅ Good: Import specific functions
import debounce from 'lodash/debounce'; // ~1KB
import cloneDeep from 'lodash/cloneDeep'; // ~1KB
import dayjs from 'dayjs'; // ~7KB (moment alternative)
// ✅ Even better: Native APIs (zero bytes!)
const formatted = Intl.DateTimeFormat('en-US', {
dateStyle: 'full',
timeStyle: 'short'
}).format(new Date());
// Replace lodash functions with native equivalents:
_.uniq([...arr]) → [...new Set(arr)]
_.cloneDeep(obj) → structuredClone(obj) // Native!
_.get(obj, 'a.b.c') → obj?.a?.b?.c
_.pick(obj, ['a', 'b']) → (({a, b}) => ({a, b}))(obj)
_.isEmpty(obj) → Object.keys(obj).length === 0
_.mapKeys(obj, fn) → Object.fromEntries(Object.entries(obj).map(...))
// Dynamic imports for rarely-used features
const openModal = () => import('./Modal').then(m => m.default.open());
// Modal code isn't in initial bundle!
Quick Wins Checklist
□ Run Lighthouse audit (target: 90+ on all categories)
□ Enable gzip/brotli compression on server
□ Add caching headers (Cache-Control)
□ Lazy load below-fold images (loading="lazy")
□ Code split routes/pages (dynamic import)
□ Remove unused dependencies (depcheck)
□ Replace moment.js with dayjs or native Intl
□ Replace lodash with native equivalents or sub-path imports
□ Use WebP/AVIF images with responsive sources
□ Add font-display: swap (prevent FOIT)
□ Minimize critical CSS (inline above-fold styles)
□ Preconnect to third-party origins
□ Defer non-critical JavaScript
□ Virtualize long lists (>100 items)
□ Add skeleton screens (perceived performance!)
Which fix here gave you the biggest speedup? What's your #1 perf tip?
Follow @armorbreak for more practical developer guides.
Top comments (0)