JavaScript Performance Patterns
Table of Contents
-
When to Use
-
Instructions
-
Details
-
Source
Runtime performance micro-patterns for JavaScript hot paths. These patterns matter most in tight loops, frequent callbacks (scroll, resize, animation frames), and data-heavy operations. They apply to any JavaScript environment — React, Vue, vanilla, Node.js.
When to Use
Reference these patterns when:
-
Profiling reveals a hot function or tight loop
-
Processing large datasets (1,000+ items)
-
Handling high-frequency events (scroll, mousemove, resize)
-
Optimizing build-time or server-side scripts
-
Reviewing code for performance in critical paths
Instructions
- Apply these patterns only in measured hot paths — code that runs frequently or processes large datasets. Don't apply them to cold code paths where readability is more important than nanosecond gains.
Details
Overview
Micro-optimizations are not a substitute for algorithmic improvements. Address the algorithm first (O(n^2) to O(n), removing waterfalls, reducing DOM mutations). Once the algorithm is right, these patterns squeeze additional performance from hot paths.
- Use Set and Map for Lookups
Impact: HIGH for large collections — O(1) vs O(n) per lookup.
Array methods like .includes() , .find() , and .indexOf() scan linearly. For repeated lookups against the same collection, convert to Set or Map first.
Avoid — O(n) per check:
const allowedIds = ['a', 'b', 'c', /* ...hundreds more */]
function isAllowed(id: string) { return allowedIds.includes(id) // scans entire array }
items.filter(item => allowedIds.includes(item.id)) // O(n * m)
Prefer — O(1) per check:
const allowedIds = new Set(['a', 'b', 'c', /* ...hundreds more */])
function isAllowed(id: string) { return allowedIds.has(id) }
items.filter(item => allowedIds.has(item.id)) // O(n)
For key-value lookups, use Map instead of scanning an array of objects:
// Avoid const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }] const user = users.find(u => u.id === targetId) // O(n)
// Prefer const userMap = new Map(users.map(u => [u.id, u])) const user = userMap.get(targetId) // O(1)
- Batch DOM Reads and Writes
Impact: HIGH — Prevents layout thrashing.
Interleaving DOM reads (e.g., offsetHeight , getBoundingClientRect ) with DOM writes (e.g., style.height = ... ) forces the browser to recalculate layout multiple times. Batch all reads first, then all writes.
Avoid — layout thrashing (read/write/read/write):
elements.forEach(el => {
const height = el.offsetHeight // read → forces layout
el.style.height = ${height * 2}px // write
})
// Each iteration forces a layout recalculation
Prefer — batched reads then writes:
// Read phase const heights = elements.map(el => el.offsetHeight)
// Write phase
elements.forEach((el, i) => {
el.style.height = ${heights[i] * 2}px
})
For complex cases, use requestAnimationFrame to defer writes to the next frame, or use a library like fastdom.
CSS class approach — single reflow:
// Avoid multiple style mutations el.style.width = '100px' el.style.height = '200px' el.style.margin = '10px'
// Prefer — one reflow el.classList.add('expanded') // or el.style.cssText = 'width:100px;height:200px;margin:10px;'
- Cache Property Access in Tight Loops
Impact: MEDIUM — Reduces repeated property resolution.
Accessing deeply nested properties or array .length in every iteration adds overhead in tight loops.
Avoid:
for (let i = 0; i < data.items.length; i++) { process(data.items[i].value.nested.prop) }
Prefer:
const { items } = data for (let i = 0, len = items.length; i < len; i++) { const val = items[i].value.nested.prop process(val) }
This matters for arrays with 10,000+ items or when called at 60fps. For small arrays or infrequent calls, the readable version is fine.
- Memoize Expensive Function Results
Impact: MEDIUM-HIGH — Avoids recomputing the same result.
When a pure function is called repeatedly with the same arguments, cache the result.
Simple single-value cache:
function memoize<T extends (...args: any[]) => any>(fn: T): T { let lastArgs: any[] | undefined let lastResult: any
return ((...args: any[]) => { if (lastArgs && args.every((arg, i) => Object.is(arg, lastArgs![i]))) { return lastResult } lastArgs = args lastResult = fn(...args) return lastResult }) as T }
const expensiveCalc = memoize((data: number[]) => { return data.reduce((sum, n) => sum + heavyTransform(n), 0) })
Multi-key cache with Map:
const cache = new Map<string, Result>()
function getResult(key: string): Result { if (cache.has(key)) return cache.get(key)! const result = computeExpensiveResult(key) cache.set(key, result) return result }
For caches that can grow unbounded, use an LRU strategy or WeakMap for object keys.
- Combine Iterations Over the Same Data
Impact: MEDIUM — Single pass instead of multiple.
Chaining .filter().map().reduce() creates intermediate arrays and iterates the data multiple times. For large arrays in hot paths, combine into a single loop.
Avoid — 3 iterations, 2 intermediate arrays:
const result = users .filter(u => u.active) .map(u => u.name) .reduce((acc, name) => acc + name + ', ', '')
Prefer — single pass:
let result = '' for (const u of users) { if (u.active) { result += u.name + ', ' } }
For small arrays (< 100 items), the chained version is fine and more readable. Optimize only when profiling shows it matters.
- Short-Circuit with Length Checks First
Impact: LOW-MEDIUM — Avoids expensive operations on empty inputs.
Before running expensive comparisons or transformations, check if the input is empty.
function findMatchingItems(items: Item[], query: string): Item[] { if (items.length === 0 || query.length === 0) return []
const normalized = query.toLowerCase() return items.filter(item => item.name.toLowerCase().includes(normalized) ) }
- Return Early to Skip Unnecessary Work
Impact: LOW-MEDIUM — Reduces average-case execution.
Structure functions to exit as soon as possible for common non-matching cases.
Avoid — always does full work:
function processEvent(event: AppEvent) { let result = null if (event.type === 'click') { if (event.target && event.target.matches('.actionable')) { result = handleAction(event) } } return result }
Prefer — exits early:
function processEvent(event: AppEvent) { if (event.type !== 'click') return null if (!event.target?.matches('.actionable')) return null return handleAction(event) }
- Hoist RegExp and Constant Creation Outside Loops
Impact: LOW-MEDIUM — Avoids repeated compilation.
Creating RegExp objects or constant values inside loops or frequently-called functions wastes CPU.
Avoid — compiles regex 10,000 times:
function validate(items: string[]) { return items.filter(item => { const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/ return pattern.test(item) }) }
Prefer — compile once:
const EMAIL_PATTERN = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/
function validate(items: string[]) { return items.filter(item => EMAIL_PATTERN.test(item)) }
- Use toSorted() , toReversed() , toSpliced() for Immutability
Impact: LOW — Correct immutability without manual copying.
The new non-mutating array methods avoid the [...arr].sort() pattern and communicate intent more clearly.
Avoid — manual copy then mutate:
const sorted = [...items].sort((a, b) => a.price - b.price) const reversed = [...items].reverse() const without = [...items]; without.splice(index, 1)
Prefer — non-mutating methods:
const sorted = items.toSorted((a, b) => a.price - b.price) const reversed = items.toReversed() const without = items.toSpliced(index, 1)
These are available in all modern browsers and Node.js 20+.
- Use requestAnimationFrame for Visual Updates
Impact: MEDIUM — Syncs with the browser's render cycle.
DOM updates triggered outside the rendering cycle (from timers, event handlers, etc.) can cause jank. Batch visual updates inside requestAnimationFrame .
Avoid — updates outside render cycle:
window.addEventListener('scroll', () => {
progressBar.style.width = ${getScrollPercent()}%
counter.textContent = ${getScrollPercent()}%
}, { passive: true })
Prefer — synced to render:
let ticking = false
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const pct = getScrollPercent()
progressBar.style.width = ${pct}%
counter.textContent = ${pct}%
ticking = false
})
ticking = true
}
}, { passive: true })
- Use structuredClone for Deep Copies
Impact: LOW — Correct deep cloning without libraries.
structuredClone() handles circular references, typed arrays, Dates, RegExps, Maps, and Sets — unlike JSON.parse(JSON.stringify()) .
// Avoid — loses Dates, Maps, Sets, undefined values const copy = JSON.parse(JSON.stringify(original))
// Prefer — handles all standard types const copy = structuredClone(original)
Note: structuredClone cannot clone functions or DOM nodes. For those cases, implement a custom clone.
- Prefer Map Over Plain Objects for Dynamic Keys
Impact: LOW-MEDIUM — Better performance for frequent additions/deletions.
V8 optimizes plain objects for static shapes. When keys are added and removed dynamically (caches, counters, registries), Map provides consistently better performance.
// Avoid for dynamic keys const counts: Record<string, number> = {} items.forEach(item => { counts[item.category] = (counts[item.category] || 0) + 1 })
// Prefer for dynamic keys const counts = new Map<string, number>() items.forEach(item => { counts.set(item.category, (counts.get(item.category) ?? 0) + 1) })
Source
Patterns from patterns.dev — JavaScript performance guidance for the broader web engineering community.