Deep dive into JavaScript performance optimization. Covers memory management, event loop understanding, code splitting, lazy loading, and profiling techniques to build faster web applications.
On a high-traffic e-commerce site, JavaScript is often the largest source of delay after the first paint. Slow scripts delay interactivity, inflate Time to Interactive, and make checkout feel broken even when the network is fine.
The goal is not "as little JavaScript as possible." It is the right JavaScript, at the right time, without blocking the main thread.
The browser has one main thread for JavaScript, style, layout, and paint. Long synchronous work starves input and animation.
// Bad: one 200ms task blocks clicks and paint
function processOrders(orders) {
return orders.map(enrichOrder).filter(isValid);
}
// Better: yield between chunks so the page stays responsive
async function processOrdersInChunks(orders, chunkSize = 50) {
const result = [];
for (let i = 0; i < orders.length; i += chunkSize) {
const chunk = orders.slice(i, i + chunkSize);
result.push(...chunk.map(enrichOrder).filter(isValid));
await new Promise((resolve) => setTimeout(resolve, 0));
}
return result;
}
Prefer scheduler.yield() or requestIdleCallback when you need to do background work without competing with user input.
Promises and queueMicrotask run before the next paint. Flooding the microtask queue can still freeze the UI:
// Dangerous: never yields to rendering
async function tightLoop() {
while (hasMoreWork()) {
await Promise.resolve(); // still a microtask
doWork();
}
}
JavaScript is garbage collected, but you can still leak memory.
import { useEffect } from 'react';
function LiveInventory({ sku }) {
useEffect(() => {
const controller = new AbortController();
const observer = new ResizeObserver(measure);
fetch(`/api/inventory/${sku}`, { signal: controller.signal });
observer.observe(document.body);
return () => {
controller.abort();
observer.disconnect();
};
}, [sku]);
return null;
}
Before pooling, shrink retained data. Keep list items as IDs plus a lookup map. Avoid storing derived arrays next to source arrays if both stay in memory.
Reading layout (offsetHeight, getBoundingClientRect) after writing styles forces the browser to recalculate immediately.
// Forces layout N times
items.forEach((el) => {
el.style.width = el.offsetWidth + 10 + 'px';
});
// Batch reads, then writes
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
Use CSS for animations (transform and opacity) instead of animating top, left, or width.
Search inputs, resize handlers, and scroll listeners should not run on every event.
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
function throttle(fn, wait) {
let last = 0;
let timer;
return (...args) => {
const now = Date.now();
const remaining = wait - (now - last);
if (remaining <= 0) {
clearTimeout(timer);
last = now;
fn(...args);
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn(...args);
}, remaining);
}
};
}
const onSearch = debounce((query) => fetchSuggestions(query), 250);
const onScroll = throttle(updateStickyHeader, 16);
For React, wrap these in useMemo/useCallback or use a library that cancels pending calls on unmount.
Ship less JavaScript on the first load. Split by route first, then by heavy widgets (charts, editors, PDFs).
import { lazy, Suspense } from 'react';
const CheckoutReview = lazy(() => import('./CheckoutReview'));
export function CheckoutPage({ step }) {
return (
<Suspense fallback={<div>Loading checkout...</div>}>
{step === 'review' ? <CheckoutReview /> : <ShippingForm />}
</Suspense>
);
}
Dynamic import() for feature flags works the same way:
async function trackPurchase(payload) {
const { analytics } = await import('./analytics');
analytics.purchase(payload);
}
Preload the next likely route on hover or when the current page is idle.
Move CPU-heavy work off the main thread: CSV parsing, search indexes, image processing, large JSON transforms.
// inventory.worker.js
self.onmessage = ({ data }) => {
const indexed = buildSearchIndex(data.products);
self.postMessage(indexed);
};
const worker = new Worker(new URL('./inventory.worker.js', import.meta.url));
worker.postMessage({ products });
worker.onmessage = ({ data }) => setIndex(data);
Keep the message payload small. Transfer ArrayBuffers when possible instead of cloning huge objects.
Guessing is slower than measuring.
export function mark(name) {
performance.mark(name);
}
export function measure(name, start, end) {
performance.measure(name, start, end);
const entry = performance.getEntriesByName(name).at(-1);
if (entry && process.env.NODE_ENV === 'production') {
analytics.track('js_timing', {
name,
duration: Math.round(entry.duration),
});
}
}
Instrument checkout, search, and first product render. Those paths pay the bills.
JavaScript performance is a product feature. Users feel it as "the site is snappy," even when they never mention JavaScript.