Don't Optimize Blindly
The cardinal rule of performance optimization: measure first. Every experienced engineer has a story about spending days optimizing the wrong function while the real bottleneck was elsewhere.
JavaScript performance optimization starts with profiling tools, not hunches. This guide shows you how to find real problems and fix them systematically.
Chrome DevTools Performance Profiling
// Manual profiling in code
console.time('expensive-operation')
const result = expensiveOperation()
console.timeEnd('expensive-operation')
// → "expensive-operation: 234.12ms"
// More detailed with performance.mark and measure
performance.mark('start-render')
renderComplexUI()
performance.mark('end-render')
performance.measure('render-time', 'start-render', 'end-render')
const entries = performance.getEntriesByName('render-time')
console.log(entries[0].duration) // milliseconds
Using the Performance tab:
- Open DevTools → Performance
- Click Record
- Perform the interaction you want to profile
- Stop recording
- Look at: Flame chart (time per function), Bottom-up (total time), Call tree
Key things to look for:
- Long tasks (red triangles in the main thread) — blocks rendering
- Forced reflows/layouts (purple events) — happens when JS reads layout after writing
- Excessive GC (garbage collection pauses)
- Unnecessary re-renders in React (Profiler panel)
The Main Thread: What Blocks It
// ❌ Long synchronous task — blocks all UI interaction
function processLargeArray(items) {
return items.map(item => {
// Simulating complex CPU work per item
let result = 0
for (let i = 0; i < 10000; i++) {
result += Math.sqrt(i * item.value)
}
return result
})
}
// ✅ Chunked processing with yielding to main thread
async function processLargeArrayChunked(items, chunkSize = 100) {
const results = []
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize)
// Process this chunk
const chunkResults = chunk.map(item => {
let result = 0
for (let j = 0; j < 10000; j++) {
result += Math.sqrt(j * item.value)
}
return result
})
results.push(...chunkResults)
// Yield to browser after each chunk (allows UI updates, input handling)
await new Promise(resolve => setTimeout(resolve, 0))
// Or use scheduler API if available:
// if ('scheduler' in window) await scheduler.yield()
}
return results
}
Web Workers: True Parallelism
// worker.js — runs in a separate thread
self.onmessage = function(e) {
const { items, id } = e.data
// CPU-intensive work — doesn't block the main thread!
const results = items.map(item => heavyComputation(item))
self.postMessage({ results, id })
}
function heavyComputation(item) {
let result = 0
for (let i = 0; i < 1_000_000; i++) {
result += Math.sqrt(i * item.value)
}
return result
}
// main.js
class WorkerPool {
constructor(size = navigator.hardwareConcurrency) {
this.workers = Array.from({ length: size }, () => new Worker('./worker.js'))
this.queue = []
this.busy = new Set()
this.callbacks = new Map()
}
run(data) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID()
this.callbacks.set(id, { resolve, reject })
this.queue.push({ data, id })
this.dispatch()
})
}
dispatch() {
const available = this.workers.find(w => !this.busy.has(w))
if (!available || this.queue.length === 0) return
const { data, id } = this.queue.shift()
this.busy.add(available)
available.onmessage = (e) => {
const { resolve } = this.callbacks.get(e.data.id)
resolve(e.data.results)
this.callbacks.delete(e.data.id)
this.busy.delete(available)
this.dispatch() // Process next in queue
}
available.postMessage({ ...data, id })
}
}
// Usage
const pool = new WorkerPool(4)
const results = await pool.run({ items: largeArray })
Memory Leaks: Finding and Fixing Them
Memory leaks in JavaScript are usually one of:
- Forgotten event listeners
- Closures holding references
- Global variables accumulating data
- Unreleased timers/intervals
// ❌ Classic memory leak: event listener not removed
class Component {
constructor(element) {
this.element = element
this.data = new Array(1000).fill({ /* large object */ })
// This closure keeps `this` (and all its data) alive
// as long as the button exists!
document.querySelector('#trigger').addEventListener('click', () => {
this.update()
})
}
destroy() {
this.element.remove()
// Bug: addEventListener not removed — component stays in memory
}
}
// ✅ Fixed: store reference and remove on destroy
class Component {
constructor(element) {
this.element = element
this.handleClick = () => this.update() // Store reference
document.querySelector('#trigger').addEventListener('click', this.handleClick)
}
destroy() {
document.querySelector('#trigger').removeEventListener('click', this.handleClick)
this.element.remove()
}
}
// ✅ Even better: AbortController pattern
class Component {
constructor(element) {
this.element = element
this.abortController = new AbortController()
document.querySelector('#trigger').addEventListener(
'click',
() => this.update(),
{ signal: this.abortController.signal } // Auto-removed when aborted
)
window.addEventListener(
'resize',
() => this.onResize(),
{ signal: this.abortController.signal }
)
}
destroy() {
this.abortController.abort() // Removes ALL listeners at once
this.element.remove()
}
}
Detecting Memory Leaks with DevTools
// 1. Take a heap snapshot before the operation
// DevTools → Memory → Take snapshot
// 2. Perform the operation that might leak
// e.g., navigate to a route and back 10 times
// 3. Take another heap snapshot
// 4. Use "Comparison" view to see what grew
// 5. Look for retained objects that shouldn't be there
// In code: use WeakMap/WeakRef for cache that shouldn't prevent GC
const cache = new WeakMap() // Doesn't prevent GC of the keys
function processElement(el) {
if (cache.has(el)) return cache.get(el)
const result = expensiveOperation(el)
cache.set(el, result)
// When el is removed from DOM and GC'd, cache entry is also cleared
return result
}
// WeakRef: hold reference without preventing GC
class ResourceManager {
constructor(resource) {
this.ref = new WeakRef(resource)
}
get() {
const resource = this.ref.deref()
if (!resource) {
// Resource was GC'd — need to re-acquire
return this.acquire()
}
return resource
}
}
Reducing GC Pressure
Garbage collection pauses cause jank. Reduce them by allocating less:
// ❌ Allocates new object every frame (60fps = 3600 objects/minute)
function gameLoop() {
const position = { x: player.x, y: player.y } // New object every frame
updateEntities(position)
requestAnimationFrame(gameLoop)
}
// ✅ Reuse objects with object pooling
class ObjectPool {
constructor(factory, reset, initialSize = 10) {
this.factory = factory
this.reset = reset
this.pool = Array.from({ length: initialSize }, factory)
}
acquire() {
return this.pool.pop() ?? this.factory()
}
release(obj) {
this.reset(obj)
this.pool.push(obj)
}
}
const vecPool = new ObjectPool(
() => ({ x: 0, y: 0 }),
(v) => { v.x = 0; v.y = 0 }
)
function gameLoop() {
const position = vecPool.acquire()
position.x = player.x
position.y = player.y
updateEntities(position)
vecPool.release(position) // Return to pool
requestAnimationFrame(gameLoop)
}
// ❌ String concatenation in hot path — creates many intermediate strings
function buildQuery(params) {
let query = ''
for (const [key, value] of Object.entries(params)) {
query += key + '=' + encodeURIComponent(value) + '&' // Multiple allocations!
}
return query.slice(0, -1)
}
// ✅ Array join — single allocation
function buildQuery(params) {
return Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&')
}
DOM Performance
// ❌ Causes multiple reflows/layouts
function updateElements(items) {
items.forEach((item, i) => {
const el = document.getElementById(`item-${i}`)
el.style.width = (item.width * 2) + 'px' // Write
el.style.height = el.offsetWidth + 'px' // Read (forces reflow!)
el.style.left = (el.getBoundingClientRect().left + 10) + 'px' // Read (reflow!)
})
}
// ✅ Batch reads, then batch writes (no interleaving)
function updateElements(items) {
// Batch all reads first
const measurements = items.map((item, i) => {
const el = document.getElementById(`item-${i}`)
return {
el,
width: item.width * 2,
currentLeft: el.getBoundingClientRect().left,
}
})
// Then batch all writes
measurements.forEach(({ el, width, currentLeft }) => {
el.style.width = width + 'px'
el.style.left = (currentLeft + 10) + 'px'
})
}
// ✅ Use requestAnimationFrame for visual updates
function updateUI(newData) {
requestAnimationFrame(() => {
// DOM writes here happen just before the browser paints
// Batched with other rAF callbacks
document.querySelector('#counter').textContent = newData.count
document.querySelector('#status').className = newData.status
})
}
// ✅ Virtual list for large collections
// Don't render 10,000 DOM nodes — only render visible ones
// Use react-window, react-virtual, or tanstack-virtual
React-Specific Performance
// useMemo: cache expensive computations
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
[items] // Only recompute when items changes
)
// useCallback: stable function references for child components
const handleUpdate = useCallback(
(id: string, value: string) => {
dispatch({ type: 'UPDATE', id, value })
},
[dispatch] // dispatch is stable from useReducer
)
// React.memo: skip re-render if props unchanged
const ExpensiveList = React.memo(function ExpensiveList({ items, onUpdate }) {
return <ul>{items.map(item => <Item key={item.id} item={item} onUpdate={onUpdate} />)}</ul>
})
// Profiler: measure render performance
import { Profiler } from 'react'
<Profiler id="ProductList" onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) { // Longer than one frame (60fps)
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms (${phase})`)
}
}}>
<ProductList />
</Profiler>
// State batching (React 18+)
// Multiple setState calls in an event handler are automatically batched
function handleClick() {
setCount(c => c + 1) // React 18: these are batched into one render
setFlag(f => !f) // Not two separate renders
}
// For truly expensive initial state:
const [state, setState] = useState(() => computeInitialState()) // Lazy init
Measuring Real-World Performance
// Performance Observer API — programmatic access to Web Vitals
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
console.log('LCP:', entry.startTime)
}
if (entry.entryType === 'layout-shift') {
if (!entry.hadRecentInput) {
console.log('CLS:', entry.value)
}
}
}
}).observe({ entryTypes: ['largest-contentful-paint', 'layout-shift'] })
// Web Vitals library (easiest approach)
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals'
function sendToAnalytics(metric) {
navigator.sendBeacon('/analytics', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
}))
}
onCLS(sendToAnalytics)
onINP(sendToAnalytics)
onLCP(sendToAnalytics)
Performance optimization is most impactful when it starts with measurement, identifies the actual bottleneck, applies the targeted fix, and measures again to confirm improvement. The profiling tools exist precisely to avoid optimizing the wrong thing.
→ Benchmark and compare the performance of different code approaches with the Benchmark Builder.