JavaScript Performance: 8 Fixes That Actually Matter (2026)
Not all performance optimizations are equal. These 8 fixes give you the biggest bang for your buck.
Fix #1: Debounce & Throttle Expensive Operations
// Problem: Search fires on every keystroke → API calls on every key
// Solution: 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 searchInput = document.getElementById('search');
searchInput.addEventListener('input', debounce(async (e) => {
const results = await fetch(`/api/search?q=${e.target.value}`);
displayResults(results);
}, 300)); // Wait 300ms after last keystroke
// 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);
}
};
}
window.addEventListener('scroll', throttle(() => {
updateScrollProgress();
}, 100)); // At most once every 100ms
// When to use which:
// Debounce: search input, resize end, form auto-save
// Throttle: scroll handlers, button click spam, mouse move tracking
Fix #2: Lazy Load Everything Possible
<!-- Native lazy loading for images -->
<img src="placeholder.jpg" data-src="photo.jpg" loading="lazy" alt="Photo" />
<!-- Intersection Observer for any element -->
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Load the heavy content
entry.target.src = entry.target.dataset.src;
observer.unobserve(entry.target); // Don't observe again
}
});
}, { rootMargin: '200px' }); // Start loading 200px before visible
document.querySelectorAll('[data-src]').forEach(el => observer.observe(el));
</script>
<!-- Dynamic imports for code splitting -->
// Instead of importing everything upfront:
import { HeavyChart } from './chart'; // ❌ Blocks initial load
import { DataGrid } from './data-grid'; // ❌ Blocks initial load
// Use dynamic imports:
const chartModule = import('./chart'); // ✅ Loaded when needed
button.addEventListener('click', async () => {
const { HeavyChart } = await chartModule; // ✅ Fetched on demand
new HeavyChart(container);
});
// React.lazy + Suspense (if using React)
const Chart = React.lazy(() => import('./Chart'));
<Suspense fallback={<Spinner />}>
<Chart data={data} />
</Suspense>
Fix #3: Virtualize Long Lists
// Problem: Rendering 10,000 rows → DOM is huge, scrolling is slow
// Solution: Only render what's visible + small buffer
class VirtualList {
constructor(container, items, rowHeight = 50) {
this.container = container;
this.items = items;
this.rowHeight = rowHeight;
this.visibleCount = Math.ceil(container.clientHeight / rowHeight) + 5; // +5 buffer
container.addEventListener('scroll', () => this.render());
window.addEventListener('resize', () => this.render());
this.render();
}
render() {
const scrollTop = this.container.scrollTop;
const startIndex = Math.max(0, Math.floor(scrollTop / this.rowHeight) - 2); // -2 buffer above
const endIndex = Math.min(this.items.length, startIndex + this.visibleCount);
const fragment = document.createDocumentFragment();
for (let i = startIndex; i < endIndex; i++) {
const row = document.createElement('div');
row.style.height = `${this.rowHeight}px`;
row.style.position = 'absolute';
row.style.top = `${i * this.rowHeight}px`;
row.textContent = this.items[i];
fragment.appendChild(row);
}
this.container.innerHTML = '';
this.container.appendChild(fragment);
this.container.style.height = `${this.items.length * this.rowHeight}px`;
}
}
// Or use a library:
// react-window, react-virtualized, tanstack-virtual
Fix #4: Optimize Images Properly
// The image optimization checklist:
// 1. Right format
// Photos → WebP or AVIF (30-70% smaller than JPEG)
// Icons/logos → SVG (infinitely scalable)
// Simple graphics → PNG with palette
// Animated → WebP animation (replaces GIF)
// 2. Right size (don't serve 4000px image for 300px display!)
<img srcset="
photo-320w.webp 320w,
photo-640w.webp 640w,
photo-1280w.webp 1280w
" sizes="(max-width: 600px) 100vw, 50vw"
src="photo-640w.webp"
alt="Description"
loading="lazy"
// 3. Blur placeholder (perceived performance!)
<img
style="background-size: cover; background-position: center;"
src="photo.jpg"
alt="Photo"
onload="this.style.background='none'"
/>
// 4. CDN + caching
// Set far-future Cache-Control for hashed assets
Cache-Control: public, max-age=31536000, immutable
// 5. Compression quality
// JPEG: quality 75-85 (sweet spot)
// WebP: quality 80-90
// Don't go higher — human eye can't tell the difference but file size doubles
Fix #5: Reduce JavaScript Bundle Size
# Analyze your bundle
npx webpack-bundle-analyzer build/stats.json
# See exactly what's in your bundle and how big each module is
# Tree shaking (remove unused code)
// ✅ ES modules enable tree shaking
export { usedFn } from './utils';
// ❌ CommonJS does NOT tree shake
module.exports = { usedFn, unusedFn }; // Both included!
# Code splitting strategies
// Route-based: Each page loads its own chunk
const Home = () => import('./pages/Home');
const Dashboard = () => import('./pages/Dashboard');
// Vendor chunks: Separate third-party code (changes rarely, caches long)
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
}
# Remove unused dependencies
npx depcheck # Find deps in package.json not used in code
npm outdated # Find outdated packages that might be smaller
Fix #6: Efficient DOM Manipulation
// ❌ Slow: Multiple DOM operations (each triggers reflow/repaint)
for (let i = 0; i < 1000; i++) {
const el = document.createElement('div');
el.textContent = `Item ${i}`;
document.getElementById('list').appendChild(el);
}
// Result: 1000 reflows!
// ✅ Fast: Document fragment (single reflow)
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const el = document.createElement('div');
el.textContent = `Item ${i}`;
fragment.appendChild(el);
}
document.getElementById('list').appendChild(fragment);
// Result: 1 reflow!
// ✅ Faster: innerHTML (browser parses HTML in one pass)
const items = Array.from({length: 1000}, (_, i) => `<div>Item ${i}</div>`).join('');
document.getElementById('list').innerHTML = items;
// Batch reads/writes (avoid layout thrashing)
// ❌ Bad: Read/write interleaved (forces synchronous layout)
el.style.left = el.offsetLeft + 10 + 'px'; // Read offsetLeft → reflow → write
el.style.top = el.offsetTop + 10 + 'px'; // Read offsetTop → reflow → write
// ✅ Good: All reads first, then all writes
const left = el.offsetLeft; // Read once
const top = el.offsetTop; // Read once
el.style.left = left + 10 + 'px'; // Write
el.style.top = top + 10 + 'px'; // Write
// Result: 1 reflow instead of 2+
// Use CSS transforms for animations (GPU accelerated, no reflow)
el.style.transform = 'translateX(100px)'; // ✅ Composited layer
el.style.left = '100px'; // ❌ Triggers layout
Fix #7: Caching Strategy
// Browser caching (HTTP headers)
// Static assets (with content hash in filename):
Cache-Control: public, max-age=31536000, immutable // Cache forever
// API responses:
Cache-Control: no-cache // Always revalidate
ETag: "v1-abc123" // For conditional requests
// Service Worker cache (offline support + speed)
const CACHE_NAME = 'app-v1';
const ASSETS = ['/', '/styles.css', '/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then(cached => {
// Return cached version, update cache in background (stale-while-revalidate)
const fetched = fetch(event.request).then(response => {
caches.open(CACHE_NAME).then(cache => cache.put(event.request, response));
return response.clone();
});
return cached || fetched;
})
);
});
// Application-level cache (avoid redundant API calls)
const apiCache = new Map();
const CACHE_TTL = 60 * 1000; // 1 minute
async function cachedFetch(url) {
const cached = apiCache.get(url);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.data; // Return from cache
}
const data = await fetch(url).then(r => r.json());
apiCache.set(url, { data, timestamp: Date.now() });
return data;
}
Fix #8: Measure Before Optimizing
// DON'T GUESS. MEASURE.
// Chrome DevTools Performance tab:
// 1. Open DevTools → Performance
// 2. Click Record → interact with page → Stop
// 3. Look for: Long tasks (>50ms), Layout shifts, Paint time
// Performance API (in your code):
const start = performance.now();
// ... do something expensive ...
const duration = performance.now() - start;
console.log(`Operation took ${duration.toFixed(2)}ms`);
// User Timing API (shows up in DevTools!):
performance.mark('fetch-start');
await fetchData();
performance.mark('fetch-end');
performance.measure('fetch-duration', 'fetch-start', 'fetch-end');
// Long Task API (detect jank):
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task detected: ${entry.duration}ms`);
// Report to analytics to track real-user performance!
}
});
observer.observe({ entryTypes: ['longtask'] });
// Core Web Vitals (what Google measures):
// LCP (Largest Contentful Paint): < 2.5s
// INP (Interaction to Next Paint): < 200ms
// CLS (Cumulative Layout Shift): < 0.1
// Measure them:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`${entry.name}: ${entry.value}`);
}
});
observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input'] });
Which fix would help YOUR app the most? What's your #1 performance tip?
Follow @armorbreak for more practical developer guides.
Top comments (0)