JavaScript — Performance
JavaScript performance optimization is about writing code that runs efficiently within the constraints of the browser — limited CPU, memory, and battery life. Modern JavaScript engines (V8, SpiderMonkey, JavaScriptCore) are highly optimized, but certain patterns can still trigger performance bottlenecks: excessive DOM manipulation, memory leaks, layout thrashing, and blocking the event loop with heavy synchronous work.
Performance optimization follows the 80/20 rule: 80% of the impact comes from 20% of optimizations. The key is measuring before optimizing. Premature optimization adds complexity without measurable benefit. This guide covers the highest-impact areas: rendering performance, memory management, network optimization, and bundle size reduction.
Performance is not just about speed — it encompasses perceived performance (how fast the user feels the page responds), memory efficiency, battery impact, and Core Web Vitals (LCP, FID, CLS). A performant application respects the user's device resources and provides a smooth, responsive experience.
| 1 | // Performance measurement basics |
| 2 | // console.time — simple, synchronous timing |
| 3 | console.time('operation'); |
| 4 | heavyComputation(); |
| 5 | console.timeEnd('operation'); // "operation: 142.5ms" |
| 6 | |
| 7 | // Performance API — high-resolution timing |
| 8 | const start = performance.now(); |
| 9 | heavyComputation(); |
| 10 | const duration = performance.now() - start; |
| 11 | console.log(`Operation took ${duration.toFixed(2)}ms`); |
| 12 | |
| 13 | // Performance marks and measures |
| 14 | performance.mark('start-task'); |
| 15 | await asyncOperation(); |
| 16 | performance.mark('end-task'); |
| 17 | performance.measure('task-duration', 'start-task', 'end-task'); |
| 18 | const entries = performance.getEntriesByName('task-duration'); |
| 19 | console.log(`Async task: ${entries[0].duration.toFixed(2)}ms`); |
| 20 | |
| 21 | // Detect long tasks (blocking main thread > 50ms) |
| 22 | const observer = new PerformanceObserver((list) => { |
| 23 | list.getEntries().forEach((entry) => { |
| 24 | console.warn(`Long task detected: ${entry.duration}ms`); |
| 25 | }); |
| 26 | }); |
| 27 | observer.observe({ type: 'longtask', buffered: true }); |
You cannot optimize what you cannot measure. The browser provides a rich set of APIs to measure performance at every level — from individual function timing to full page load lifecycle. The Performance API is the foundation, supplemented by PerformanceObserver for monitoring specific metrics, and performance.memory (Chrome only) for memory profiling.
| Metric | API | Target |
|---|---|---|
| LCP | Largest Contentful Paint | < 2.5s |
| FID | First Input Delay | < 100ms |
| CLS | Cumulative Layout Shift | < 0.1 |
| TBT | Total Blocking Time | < 200ms |
| TTFB | Time to First Byte | < 800ms |
| INP | Interaction to Next Paint | < 200ms |
| 1 | // Core Web Vitals measurement |
| 2 | function observeWebVitals() { |
| 3 | // Largest Contentful Paint |
| 4 | const lcpObserver = new PerformanceObserver((list) => { |
| 5 | const entries = list.getEntries(); |
| 6 | const lastEntry = entries[entries.length - 1]; |
| 7 | console.log(`LCP: ${lastEntry.startTime.toFixed(0)}ms`); |
| 8 | }); |
| 9 | lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }); |
| 10 | |
| 11 | // First Input Delay |
| 12 | const fidObserver = new PerformanceObserver((list) => { |
| 13 | list.getEntries().forEach((entry) => { |
| 14 | console.log(`FID: ${entry.processingStart - entry.startTime}ms`); |
| 15 | }); |
| 16 | }); |
| 17 | fidObserver.observe({ type: 'first-input', buffered: true }); |
| 18 | |
| 19 | // Cumulative Layout Shift |
| 20 | const clsObserver = new PerformanceObserver((list) => { |
| 21 | let cls = 0; |
| 22 | list.getEntries().forEach((entry) => { |
| 23 | if (!entry.hadRecentInput) cls += entry.value; |
| 24 | }); |
| 25 | console.log(`CLS: ${cls.toFixed(3)}`); |
| 26 | }); |
| 27 | clsObserver.observe({ type: 'layout-shift', buffered: true }); |
| 28 | } |
| 29 | |
| 30 | // Frame rate monitoring (for animations/rendering) |
| 31 | let frameCount = 0; |
| 32 | let lastFpsTime = performance.now(); |
| 33 | |
| 34 | function measureFPS() { |
| 35 | frameCount++; |
| 36 | const now = performance.now(); |
| 37 | if (now - lastFpsTime >= 1000) { |
| 38 | console.log(`FPS: ${frameCount}`); |
| 39 | frameCount = 0; |
| 40 | lastFpsTime = now; |
| 41 | } |
| 42 | requestAnimationFrame(measureFPS); |
| 43 | } |
| 44 | |
| 45 | // Memory usage (Chrome only) |
| 46 | if (performance.memory) { |
| 47 | setInterval(() => { |
| 48 | const { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit } = performance.memory; |
| 49 | const usedMB = (usedJSHeapSize / 1048576).toFixed(1); |
| 50 | const totalMB = (totalJSHeapSize / 1048576).toFixed(1); |
| 51 | console.log(`Heap: ${usedMB}MB / ${totalMB}MB`); |
| 52 | }, 5000); |
| 53 | } |
pro tip
DOM operations are among the most expensive operations in browser JavaScript. Every read/write to the DOM can trigger layout recalculations (reflow) and repaints. The key optimization is to batch DOM reads together, then batch DOM writes together — avoiding layout thrashing where interleaved reads and writes force the browser to recalculate layout repeatedly.
| 1 | // Layout thrashing — BAD: interleaved reads and writes |
| 2 | function badPattern(elements) { |
| 3 | elements.forEach(el => { |
| 4 | const width = el.offsetWidth; // read (forces layout) |
| 5 | el.style.width = (width + 10) + 'px'; // write (invalidates layout) |
| 6 | const height = el.offsetHeight; // read (re-forces layout!) |
| 7 | el.style.height = (height + 10) + 'px'; // write |
| 8 | }); |
| 9 | } |
| 10 | // Each iteration forces TWO layout recalculations |
| 11 | |
| 12 | // Batched — GOOD: separate reads from writes |
| 13 | function goodPattern(elements) { |
| 14 | // Batch all reads first |
| 15 | const sizes = elements.map(el => ({ |
| 16 | width: el.offsetWidth, |
| 17 | height: el.offsetHeight |
| 18 | })); |
| 19 | // Batch all writes after |
| 20 | elements.forEach((el, i) => { |
| 21 | el.style.width = (sizes[i].width + 10) + 'px'; |
| 22 | el.style.height = (sizes[i].height + 10) + 'px'; |
| 23 | }); |
| 24 | } |
| 25 | // Only TWO layout recalculations total (one read batch, one write batch) |
| 26 | |
| 27 | // Document fragment — batch DOM insertions |
| 28 | const fragment = document.createDocumentFragment(); |
| 29 | for (let i = 0; i < 1000; i++) { |
| 30 | const li = document.createElement('li'); |
| 31 | li.textContent = `Item ${i}`; |
| 32 | fragment.appendChild(li); |
| 33 | } |
| 34 | list.appendChild(fragment); // single reflow instead of 1000 |
| 35 | |
| 36 | // requestAnimationFrame — sync with paint cycle |
| 37 | function animate() { |
| 38 | element.style.transform = `translateX(${x}px)`; |
| 39 | x += 1; |
| 40 | requestAnimationFrame(animate); |
| 41 | } |
| 42 | |
| 43 | // Avoid forced reflows — common triggering properties |
| 44 | // offsetTop, offsetLeft, offsetWidth, offsetHeight |
| 45 | // scrollTop, scrollLeft, scrollWidth, scrollHeight |
| 46 | // clientTop, clientLeft, clientWidth, clientHeight |
| 47 | // getComputedStyle(), getBoundingClientRect() |
| 48 | // All of these force the browser to compute layout immediately |
info
JavaScript uses automatic garbage collection (GC), but memory leaks still happen. Common leak patterns include forgotten event listeners, detached DOM references, closures holding large objects, global variables, and growing caches without bounds. Memory leaks cause progressive slowdowns, jank, and eventual crashes — especially in long-lived single-page applications.
| Leak Pattern | Cause | Solution |
|---|---|---|
| DOM leaks | Removed DOM nodes still referenced in JS | Nullify references on cleanup |
| Listener leaks | Event listeners not removed when element is removed | Use AbortController or removeEventListener |
| Closure leaks | Closures that retain large object references | Nullify unused references inside closures |
| Global leaks | Accidental global variables (no strict mode) | Use "use strict" and linting |
| Cache leaks | Unbounded caches/Maps/Sets | Set size limits, use WeakMap/WeakSet |
| Timer leaks | setInterval not cleared when component unmounts | Always store and clear timer IDs |
| 1 | // WeakMap — keys can be garbage collected |
| 2 | const cache = new WeakMap(); |
| 3 | |
| 4 | function processData(obj) { |
| 5 | if (cache.has(obj)) { |
| 6 | return cache.get(obj); |
| 7 | } |
| 8 | const result = expensiveComputation(obj); |
| 9 | cache.set(obj, result); |
| 10 | return result; |
| 11 | } |
| 12 | // When obj is no longer referenced, WeakMap entry is GC'd automatically |
| 13 | |
| 14 | // WeakSet — similar for unique object tracking |
| 15 | const processed = new WeakSet(); |
| 16 | |
| 17 | function markProcessed(obj) { |
| 18 | processed.add(obj); |
| 19 | } |
| 20 | |
| 21 | function isProcessed(obj) { |
| 22 | return processed.has(obj); |
| 23 | } |
| 24 | |
| 25 | // AbortController — clean up event listeners |
| 26 | function setupListeners() { |
| 27 | const controller = new AbortController(); |
| 28 | const { signal } = controller; |
| 29 | |
| 30 | window.addEventListener('resize', handleResize, { signal }); |
| 31 | document.addEventListener('click', handleClick, { signal }); |
| 32 | |
| 33 | // Cleanup: controller.abort() removes ALL listeners at once |
| 34 | return () => controller.abort(); |
| 35 | } |
| 36 | |
| 37 | // Avoid detached DOM references |
| 38 | function leakyComponent() { |
| 39 | const elements = []; |
| 40 | for (let i = 0; i < 1000; i++) { |
| 41 | const div = document.createElement('div'); |
| 42 | document.body.appendChild(div); |
| 43 | elements.push(div); |
| 44 | } |
| 45 | // Later: document.body.innerHTML = '' — but elements array still holds refs! |
| 46 | // Fix: elements.length = 0; // Release all references |
| 47 | } |
| 48 | |
| 49 | // Memory-efficient array handling |
| 50 | // Avoid splice() on large arrays — it shifts all elements |
| 51 | // Use pop()/push() for stack operations, shift()/unshift() sparingly |
| 52 | // For stable arrays, use index marking instead of deletion |
| 53 | |
| 54 | // Object pooling — reuse objects instead of allocating new ones |
| 55 | class ObjectPool { |
| 56 | constructor(factory, reset, initialSize = 10) { |
| 57 | this.factory = factory; |
| 58 | this.reset = reset; |
| 59 | this.pool = Array.from({ length: initialSize }, () => factory()); |
| 60 | } |
| 61 | |
| 62 | acquire() { |
| 63 | return this.pool.pop() || this.factory(); |
| 64 | } |
| 65 | |
| 66 | release(obj) { |
| 67 | this.reset(obj); |
| 68 | this.pool.push(obj); |
| 69 | } |
| 70 | } |
warning
Rendering performance determines how smoothly the page feels. The browser rendering pipeline — JavaScript, Style, Layout, Paint, Composite — must complete within 16.6ms (60fps) for smooth animations. Each step in the pipeline can become a bottleneck. The goal is to minimize or skip steps where possible: changing transform and opacity only triggers compositing (the cheapest step).
| 1 | // The rendering pipeline steps |
| 2 | // 1. JavaScript → executes your code |
| 3 | // 2. Style → recalculates CSS rules |
| 4 | // 3. Layout → computes geometry (most expensive) |
| 5 | // 4. Paint → fills in pixels |
| 6 | // 5. Composite → layers are combined (GPU, cheapest) |
| 7 | |
| 8 | // Only trigger compositing when possible: |
| 9 | // CSS properties that ONLY trigger composite: |
| 10 | // transform, opacity |
| 11 | // CSS properties that trigger layout + paint + composite: |
| 12 | // width, height, margin, padding, top, left, border |
| 13 | // font-size, line-height, display, position, float |
| 14 | |
| 15 | // requestAnimationFrame — sync with browser paint cycle |
| 16 | function smoothAnimation(element) { |
| 17 | let start = performance.now(); |
| 18 | const duration = 2000; |
| 19 | |
| 20 | function animate(now) { |
| 21 | const elapsed = now - start; |
| 22 | const progress = Math.min(elapsed / duration, 1); |
| 23 | const eased = easeInOutCubic(progress); |
| 24 | |
| 25 | element.style.transform = `translateX(${eased * 300}px)`; |
| 26 | element.style.opacity = 1 - eased * 0.5; |
| 27 | |
| 28 | if (progress < 1) { |
| 29 | requestAnimationFrame(animate); |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | requestAnimationFrame(animate); |
| 34 | } |
| 35 | |
| 36 | // Avoid layout thrashing — use will-change for frequently animated props |
| 37 | element.style.willChange = 'transform'; |
| 38 | |
| 39 | // Debounce expensive handlers (resize, scroll) |
| 40 | function debounce(fn, ms = 100) { |
| 41 | let timer; |
| 42 | return (...args) => { |
| 43 | clearTimeout(timer); |
| 44 | timer = setTimeout(() => fn(...args), ms); |
| 45 | }; |
| 46 | } |
| 47 | |
| 48 | // Throttle for continuous events |
| 49 | function throttle(fn, ms = 16) { |
| 50 | let last = 0; |
| 51 | return (...args) => { |
| 52 | const now = performance.now(); |
| 53 | if (now - last >= ms) { |
| 54 | last = now; |
| 55 | fn(...args); |
| 56 | } |
| 57 | }; |
| 58 | } |
| 59 | |
| 60 | // Use passive event listeners for scroll/touch |
| 61 | document.addEventListener('scroll', handler, { passive: true }); |
| 62 | // passive: true tells the browser you won't call preventDefault() |
| 63 | // Allows the browser to optimize scrolling immediately |
best practice
Network requests are often the biggest bottleneck in web application performance. Each request involves DNS lookup, TCP handshake (and TLS for HTTPS), latency, and bandwidth constraints. Optimizations focus on reducing the number of requests, minimizing payload size, and leveraging browser caching effectively.
| 1 | // Resource Hints — preload critical resources |
| 2 | <link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin /> |
| 3 | <link rel="preconnect" href="https://api.example.com" /> |
| 4 | <link rel="dns-prefetch" href="https://cdn.example.com" /> |
| 5 | <link rel="prefetch" href="/next-page.js" as="script" /> |
| 6 | |
| 7 | // Programmatic preloading with JavaScript |
| 8 | const link = document.createElement('link'); |
| 9 | link.rel = 'preload'; |
| 10 | link.as = 'image'; |
| 11 | link.href = 'hero-image.webp'; |
| 12 | document.head.appendChild(link); |
| 13 | |
| 14 | // Request batching — combine multiple requests |
| 15 | class RequestBatcher { |
| 16 | constructor(url, maxBatch = 10, debounceMs = 50) { |
| 17 | this.url = url; |
| 18 | this.maxBatch = maxBatch; |
| 19 | this.debounceMs = debounceMs; |
| 20 | this.queue = []; |
| 21 | this.timer = null; |
| 22 | } |
| 23 | |
| 24 | add(item) { |
| 25 | this.queue.push(item); |
| 26 | if (this.queue.length >= this.maxBatch) { |
| 27 | this.flush(); |
| 28 | } else if (!this.timer) { |
| 29 | this.timer = setTimeout(() => this.flush(), this.debounceMs); |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | async flush() { |
| 34 | if (this.queue.length === 0) return; |
| 35 | clearTimeout(this.timer); |
| 36 | this.timer = null; |
| 37 | |
| 38 | const batch = this.queue.splice(0, this.maxBatch); |
| 39 | try { |
| 40 | const response = await fetch(this.url, { |
| 41 | method: 'POST', |
| 42 | body: JSON.stringify({ items: batch }), |
| 43 | }); |
| 44 | const results = await response.json(); |
| 45 | return results; |
| 46 | } catch (err) { |
| 47 | console.error('Batch request failed:', err); |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Connection pooling — reuse connections with keep-alive |
| 53 | // Enabled by default in HTTP/1.1 and HTTP/2 |
| 54 | // HTTP/2 allows multiplexing multiple requests over a single connection |
| 55 | |
| 56 | // Service Worker caching — offline-first strategy |
| 57 | self.addEventListener('fetch', (event) => { |
| 58 | event.respondWith( |
| 59 | caches.match(event.request).then((cached) => { |
| 60 | return cached || fetch(event.request).then((response) => { |
| 61 | return caches.open('api-cache-v1').then((cache) => { |
| 62 | cache.put(event.request, response.clone()); |
| 63 | return response; |
| 64 | }); |
| 65 | }); |
| 66 | }) |
| 67 | ); |
| 68 | }); |
pro tip
JavaScript bundle size directly impacts page load time — every kilobyte of JavaScript must be downloaded, parsed, compiled, and executed. Modern bundlers (webpack, Vite, esbuild, Rollup) provide tools to split code, tree-shake unused exports, and compress output. The goal is to ship only the code needed for the initial render and defer everything else.
| Technique | Impact | Tool/Method |
|---|---|---|
| Tree shaking | Removes unused exports | ES modules + sideEffects: false |
| Code splitting | Lazy load route/page chunks | import() dynamic imports |
| Minification | Removes whitespace, renames vars | Terser, esbuild, swc |
| Compression | Gzip/Brotli on the wire | Server config (nginx, CDN) |
| Scope hoisting | Reduces module wrapper overhead | webpack's ModuleConcatenationPlugin |
| Dynamic imports | Defer non-critical code | React.lazy, dynamic import() |
| 1 | // Dynamic imports — code splitting |
| 2 | // Instead of: import { HeavyComponent } from './HeavyComponent'; |
| 3 | // Do this: |
| 4 | const HeavyComponent = React.lazy(() => import('./HeavyComponent')); |
| 5 | |
| 6 | // Usage with Suspense |
| 7 | <React.Suspense fallback={<Loading />}> |
| 8 | <HeavyComponent /> |
| 9 | </React.Suspense> |
| 10 | |
| 11 | // Route-level code splitting (React Router) |
| 12 | const Dashboard = React.lazy(() => import('./pages/Dashboard')); |
| 13 | const Settings = React.lazy(() => import('./pages/Settings')); |
| 14 | |
| 15 | // Conditional imports — load on interaction |
| 16 | button.addEventListener('click', async () => { |
| 17 | const { showChart } = await import('./chart-library'); |
| 18 | showChart(data); |
| 19 | }); |
| 20 | |
| 21 | // Preload critical chunks while idle |
| 22 | if ('requestIdleCallback' in window) { |
| 23 | requestIdleCallback(() => { |
| 24 | import('./heavy-analytics-module'); |
| 25 | }); |
| 26 | } |
| 27 | |
| 28 | // Tree shaking — import only what you need |
| 29 | // BAD: import { lodash } from 'lodash'; |
| 30 | // GOOD: import debounce from 'lodash/debounce'; |
| 31 | // Or better: import { debounce } from 'lodash-es'; |
| 32 | |
| 33 | // Webpack analyzer configuration |
| 34 | // Add to webpack.config.js: |
| 35 | // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; |
| 36 | // plugins: [new BundleAnalyzerPlugin()] |
| 37 | |
| 38 | // Package.json optimization — check bundlephobia.com for package size |
| 39 | // Prefer smaller alternatives: |
| 40 | // date-fns (80KB) vs moment (230KB) |
| 41 | // zustand (1KB) vs redux (12KB) + react-redux (7KB) |
| 42 | // preact (3KB) vs react (42KB) — if you don't need synthetic events |
info
pro tip